Spring Security 的 Web 应用和指纹登录实践

前言

Java 开发人员在解决 Web 应用安全相关的问题时,通常会采用两个非常流行的安全框架,Shiro 和 Spring Security。Shiro
配置简单,上手快,满足一般应用的安全需求,但是功能相对单一。Spring Security 安全粒度细,与 Spring Framework
无缝集成,满足绝大多数企业级应用的安全需求,但是配置复杂,学习曲线陡峭。

Spring Security 相对 Shiro 功能强大,并且 Spring Framework,Spring Boot,Spring Cloud 对 Spring
Security 的支持更加友好 (毕竟是 "亲儿子")。本文将介绍 Spring Security 的架构设计、核心组件,在 Web
应用中的开发方式,最后以一个指纹登录的实例收尾。

Spring Security
核心设计

Spring Security 有五个核心组件:SecurityContext、SecurityContextHolder、Authentication、Userdetails 和
AuthenticationManager。下面分别介绍一下各个组件。

SecurityContext

SecurityContext 即安全上下文,关联当前用户的安全信息。用户通过 Spring Security 的校验之后,SecurityContext 会存储验证信息,下文提到的
Authentication 对象包含当前用户的身份信息。SecurityContext 的接口签名如清单 1 所示:

清单 1. SecurityContext
的接口签名

public interface SecurityContext extends Serializable {
       Authentication getAuthentication();
       void setAuthentication(Authentication authentication);
}

SecurityContext 存储在 SecurityContextHolder 中。

SecurityContextHolder

SecurityContextHolder 存储 SecurityContext 对象。SecurityContextHolder 是一个存储代理,有三种存储模式分别是:

  • MODE_THREADLOCAL:SecurityContext 存储在线程中。
  • MODE_INHERITABLETHREADLOCAL:SecurityContext 存储在线程中,但子线程可以获取到父线程中的 SecurityContext。
  • MODE_GLOBAL:SecurityContext 在所有线程中都相同。

SecurityContextHolder 默认使用 MODE_THREADLOCAL 模式,SecurityContext 存储在当前线程中。调用
SecurityContextHolder 时不需要显示的参数传递,在当前线程中可以直接获取到 SecurityContextHolder 对象。但是对于很多 C
端的应用(音乐播放器,游戏等等),用户登陆完毕,在软件的整个生命周期中只有当前登陆用户,面对这种情况 SecurityContextHolder 更适合采用 MODE_GLOBAL
模式,SecurityContext 相当于存储在应用的进程中,SecurityContext 在所有线程中都相同。

Authentication

Authentication 即验证,表明当前用户是谁。什么是验证,比如一组用户名和密码就是验证,当然错误的用户名和密码也是验证,只不过 Spring Security
会校验失败。Authentication 接口签名如清单 2 所示:

清单 2. Authentication
的接口签名

public interface Authentication extends Principal, Serializable {
       Collection<? extends GrantedAuthority> getAuthorities();
       Object getCredentials();
       Object getDetails();
       Object getPrincipal();
       boolean isAuthenticated();
       void setAuthenticated(boolean isAuthenticated);
}

Authentication 是一个接口,实现类都会定义 authorities,credentials,details,principal,authenticated
等字段,具体含义如下:

getAuthorities
getCredentials
getDetails
getPrincipal
isAuthenticated
setAuthenticated

在验证前,principal 填充的是用户名,credentials 填充的是密码,detail 填充的是用户的 IP 或者经纬度之类的信息。通过验证后,Spring
Security 对 Authentication 重新注入,principal 填充用户信息(包含用户名、年龄等), authorities
会填充用户的角色信息,authenticated 会被设置为 true。重新注入的 Authentication 会被填充到 SecurityContext 中。

UserDetails

UserDetails 提供 Spring Security 需要的用户核心信息。UserDetails 的接口签名如清单 3 所示:

清单 3. UserDetails
的接口签名

public interface UserDetails extends Serializable {
       Collection<? extends GrantedAuthority> getAuthorities();
       String getPassword();
       String getUsername();
       boolean isAccountNonExpired();
       boolean isAccountNonLocked();
       boolean isCredentialsNonExpired();
       boolean isEnabled();
}

UserDetails 用 isAccountNonExpired
, isAccountNonLocked
isCredentialsNonExpired
isEnabled
表示用户的状态(与下文中提到的 DisabledException
LockedException
BadCredentialsException
相对应),具体含义如下:

getAuthorites
getPassword
getUserName
isAccountNonExpired
isAccountNonLocked
isCredentialsNonExpired
isEnabled

UserDetails 也是一个接口,实现类都会继承当前应用的用户信息类,并实现 UserDetails 的接口。假设应用的用户信息类是 User,自定义的
CustomUserdetails 继承 User 类并实现 UserDetails 接口。

AuthenticationManager

AuthenticationManager 负责校验 Authentication 对象。在 AuthenticationManager 的 authenticate
函数中,开发人员实现对 Authentication 的校验逻辑。如果 authenticate 函数校验通过,正常返回一个重新注入的 Authentication
对象;校验失败,则抛出 AuthenticationException 异常。authenticate 函数签名如清单 4 所示:

清单 4. authenticate
函数签名

Authentication authenticate(Authentication authentication)throws AuthenticationException;

AuthenticationManager 可以将异常抛出的更加明确:

DisabledException
LockedException
BadCredentialsException

重新注入的 Authentication 会包含当前用户的详细信息,并且被填充到 SecurityContext 中,这样 Spring Security
的验证流程就完成了,Spring Security 可以识别到 "你是谁"。

基本校验流程示例

下面采用 Spring Security 的核心组件写一个最基本的用户名密码校验示例,如清单 5 所示:

清单 5. Spring Security
核心组件伪代码

AuthenticationManager amanager = new CustomAuthenticationManager();
Authentication namePwd = new CustomAuthentication(“name”, “password”);
try {
       Authentication result = amanager.authenticate(namePwd);
       SecurityContextHolder.getContext.setAuthentication(result);
} catch(AuthenticationException e) {
       // TODO 验证失败
}

Spring Security 的核心组件易于理解,其基本校验流程是: 验证信息传递过来,验证通过,将验证信息存储到 SecurityContext
中;验证失败,做出相应的处理。

Spring Security 在 Web
中的设计

Spring Security 的一个常见应用场景就是 Web。下面讨论 Spring Security 在 Web 中的使用方式。

Spring Security
最简登陆实例

Spring Security 在 Web 中的使用相对要复杂一点,会涉及到很多组件。现在给出自定义登陆的伪代码,如清单 6 所示。您可以 点击这里
,查看完整的代码。

清单 6. Web
登录伪代码

@Controller
public class UserController { 

       @PostMapping(“/login”)
       public void login(String name, String password){
              matchNameAndPassword(name, password);
              User user = getUser(name);
              Authentication auth = new CustomAuthentication(user, password);
              auth.setAuthenticated(true);
              SecurityContextHolder.getContext.setAuthentication(auth);
       }
}

观察代码会发现,如果用 Spring Security 来集成已存在的登录逻辑,真正和 Spring Security 关联的代码只有短短 3 行。验证逻辑可以不经过
AuthenticationManager,真正需要做的就是把经过验证的用户信息注入到 Authentication 中,并将 Authentication 填充到
SecurityContext 中。在实际情况中,登录逻辑的确可以这样写,尤其是已经存在登录逻辑的时候,通常会这样写。这样写虽然方便,但是不符合 Spring Security 在
Web 中的架构设计。

Spring Security 在 Web
中的核心组件

下面介绍在 Web 环境中 Spring Security 的核心组件。

FilterChainProxy

FilterChaniProxy 是 FilterChain 代理。FilterChain 维护了一个 Filter 队列,这些 Filter 为 Spring Security
提供了强大的功能。一个很常见的问题是:Spring Security 在 Web 中的入口是哪里?答案是 Filter。Spring Security 在 Filter 中创建
Authentication 对象,并调用 AuthenticationManager 进行校验。Spring Security 选择 Filter,而没有采用上文中
Controller 的方式有以下优点。Spring Security 依赖 J2EE 标准,无需依赖特定的 MVC 框架。另一方面 Spring MVC 通过 Servlet
做请求转发,如果 Spring Security 采用 Servlet,那么 Spring Security 和 Spring MVC 的集成会存在问题。FilterChain
维护了很多 Filter,每个 Filter 都有自己的功能,因此在 Spring Security 中添加新功能时,推荐通过 Filter 的方式来实现。

ProviderManager

ProviderManager 是 AuthenticationManager 的实现类。ProviderManager 并没有实现对 Authentication
的校验功能,而是采用代理模式将校验功能交给 AuthenticationProvider 去实现。这样设计是因为在 Web
环境中可能会支持多种不同的验证方式,比如用户名密码登录、短信登录、指纹登录等等,如果每种验证方式的代码都写在 ProviderManager
中,想想都是灾难。因此为每种验证方式提供对应的 AuthenticationProvider,ProviderManager 将验证任务代理给对应的
AuthenticationProvider,这是一种不错的解决方案。在 ProviderManager 中可以找到以下代码,如清单 7 所示:

清单 7. ProviderManager
代码片段

private List<AuthenticationProvider> providers;
public Authentication authenticate(Authentication authentication)
                      throws AuthenticationException {
       ......
       for (AuthenticationProvider provider : getProviders()) {
              if (!provider.supports(toTest)) {
                      continue;
              }
              try {
                      result = provider.authenticate(authentication);
                      if (result != null) {
                             copyDetails(authentication, result);
                             break;
                      }
              }
       }
}

ProviderManager 维护了一个 AuthenticationProvider 队列。当 Authentication 传递进来时,ProviderManager 通过
supports 函数查找支持校验的 AuthenticationProvider。如果没有找到支持的 AuthenticationProvider 将抛出 ProviderNotFoundException
异常。

AuthenticationProvider

AuthenticationProvider 是在 Web 环境中真正对 Authentication 进行校验的组件。其接口签名如清单 8 所示:

清单 8. AuthenticationProvider
的接口签名

public interface AuthenticationProvider {
       Authentication authenticate(Authentication authentication)
                      throws AuthenticationException;
       boolean supports(Class<?> authentication);
}

其中,authenticate 函数用于校验 Authentication 对象;supports 函数用于判断 provider 是否支持校验 Authentication
对象。

当应用添加新的验证方式时,验证逻辑需要写在对应 AuthenticationProvider 中的 authenticate 函数中。验证通过返回一个重新注入的
Authentication,验证失败抛出 AuthenticationException
异常。

Spring Security 在 Web
中的认证示例

下面以用户名密码登录为例来梳理 Spring Security 在 Web 中的认证流程。上文提到 Spring Security 是以 Filter
来作为校验的入口点。在用户名密码登录中对应的 Filter 是 UsernamePasswordAuthenticationFilter。attemptAuthentication
函数会执行调用校验的逻辑。在 attemptAuthentication 函数中,可以找到以下代码,如清单 9 所示:

清单 9. attemptAuthentication
函数代码片段

public Authentication attemptAuthentication(HttpServletRequest request,HttpServletResponse 
response) throws AuthenticationException {
       ......
       UsernamePasswordAuthenticationToken authRequest = new 
UsernamePasswordAuthenticationToken(username, password);
       setDetails(request, authRequest);
       return this.getAuthenticationManager().authenticate(authRequest);
}

attemptAuthentication 函数会调用 AuthenticationManager 执行校验逻辑,并获取到重新注入后的 Authentication。在
UsernamePasswordAuthenticationFilter 父类 AbstractAuthenticationProcessingFilter 的
successfulAuthentication 函数中发现以下代码,如清单 10 所示:

清单 10. successAuthentication
函数

protected void successfulAuthentication(HttpServletRequest request,
                      HttpServletResponse response, FilterChain chain, Authentication 
authResult)throws IOException, ServletException {
       ......         SecurityContextHolder.getContext().setAuthentication(authResult);
       ......
}

successfulAuthentication 函数会把重新注入的 Authentication 填充到 SecurityContext 中,完成验证。

在 Web 中,AuthenticationManager 的实现类 ProviderManager 并没有实现校验逻辑,而是代理给 AuthenticationProvider,
在用户名密码登录中就是 DaoAuthenticationProvider。DaoAuthenticationProvider 主要完成 3 个功能:获取
UserDetails、校验密码、重新注入 Authentication。在 authenticate 函数中发现以下代码,如清单 11 所示:

清单 11.
DaoAuthenticationProvider.authenticate
函数签名

public Authentication authenticate(Authentication authentication)
                      throws AuthenticationException {
       ......
       // 获取 UserDetails
       UserDetails user = this.userCache.getUserFromCache(username);
       if (user == null) {
              cacheWasUsed = false;
              try {
                      user = retrieveUser(username,					
       (UsernamePasswordAuthenticationToken) authentication);
              }
              ......
       }
       ......
       try {
              ......
              //校验密码
              additionalAuthenticationChecks(
                      user,
              (UsernamePasswordAuthenticationToken) authentication
              );
       }
       ......
       // 从新注入 Authentication
       return createSuccessAuthentication(
                      principalToReturn, 
                      authentication, 
                      user
               );
}

首先从 userCache 缓存中查找 UserDetails, 如果缓存中没有获取到,调用 retrieveUser 函数获取 UserDetails。retrieveUser
函数签名如清单 12 所示:

清单 12. retrieveUser
函数签名

protected final UserDetails retrieveUser(String username,
       UsernamePasswordAuthenticationToken authentication)
                      throws AuthenticationException {
       UserDetails loadedUser;
       try {
               loadedUser = this.getUserDetailsService().loadUserByUsername(username);
       }
       ......
       return loadedUser;
}

retrieveUser 函数调用 UserDetailsService 获取 UserDetails 对象。UserDetailsService 接口签名如清单 13
所示:

清单 13. UserDetailsService
接口签名

public interface UserDetailsService {
       UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

UserDetailsService 非常简单,只有一个 loadUserByUserName 函数,函数参数虽然名为
username,但只要是用户的唯一标识符即可。下面是基于数据库存储的简单示例, 如清单 14 所示:

清单 14. CustomUserDetailsService
类签名

public class CustomUserDetailsService implements UserDetailsService {
    
    @Autowired
    private UserDao userDao;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException 
{
        User user = userDao.findByName(username);
        if(Objects.isNull(user)) {
           throw new UsernameNotFoundException();
        }
        UserDetails details = new CustomUserDetails(user);
        return details;
    }
}

调用 UserDao 获取 User 对象,将 User 对象包装成 UserDetails 对象。如果没有找到 User 对象,需要抛出 UsernameNotFoundException
异常。

DaoAuthenticationProvider 密码校验调用 additionalAuthenticationChecks 函数,具体通过 PasswordEncoder
比对用户输入的密码和存储在应用中的密码是否相等,如果不相等,抛出 BadCredentialsException
异常。

DaoAuthenticationProvider 对 Authentication 对象的重新注入通过调用 createSuccessAuthentication 函数, 如清单
15 所示:

清单 15. createSuccessAuthentication
函数签名

protected Authentication createSuccessAuthentication(Object principal,
                      Authentication authentication, UserDetails user) {
        UsernamePasswordAuthenticationToken result = new 		
        UsernamePasswordAuthenticationToken(
               principal, 
               authentication.getCredentials(),			
               authoritiesMapper.mapAuthorities(user.getAuthorities())
        );
        result.setDetails(authentication.getDetails());
        return result;
}

以上就是 Spring Security 在 Web 环境中对于用户名密码校验的整个流程,简言之:

  1. UsernamePasswordAuthenticationFilter 接受用户名密码登陆请求,将 Authentication 传递给 ProviderManager
    进行校验。
  2. ProviderManager 将校验任务代理给 DaoAuthenticationProvider。
  3. DaoAuthenticationProvider 对 Authentication 的用户名和密码进行校验,校验通过后返回重新注入的 Authentication
    对象。
  4. UsernamePasswordAuthenticationFilter 将重新注入的 Authentication 对象填充到 SecurityContext 中。

指纹登陆实践

指纹登陆和用户名密码登陆区别很小,只是将密码换成了指纹特征值。下面采用 Spring Security 推荐写法 Filter-AuthenticationProvider
的形式来定义相关组件以实现指纹登录。完整的项目地址: https://github.com/springAppl/rachel

FingerPrintToken

FingerPrintToken 增加 name 和 fingerPrint 字段,分别代表用户名和指纹特征值,如清单 16 所示:

清单 16. FingerPrintToken
函数签名

public class FingerPrintToken implements Authentication {
       private String name;    
       private String fingerPrint;
       ......
}

FingerPrintFilter

FingerPrintFilter 处理指纹登陆请求,调用 AuthenticationManager 进行验证,验证通过后调用 SecurityContextHolder
将重新注入的 Authentication 填充到 SecurityContext 中,如清单 17 所示:

清单 17. doFilter
函数签名

public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, 
FilterChain filterChain) throws IOException, ServletException {
       if (Objects.equals(httpServletRequest.getRequestURI(), "/api/finger-print")) {
               // 调用 AuthenticationManager, 并填充 SecurityContext
        }
}

FingerPrintProvider

FingerPrintProvider 负责处理 FingerPrintToken,需要在 supports 函数中支持处理
FingerPrintToken。authenticate 函数负责 UserDetails 获取,指纹校验,FingerPrintToken 的重新注入。

FingerPrintUserDetails

FingerPrintUserDetails 继承 User 并实现 UserDetails 的方法,应用的用户信息可以加载到 Spring Security 中使用。

FingerPrintUserDetailsService

FingerPrintUserDetailsService 获取 FingerUserDetails。通过 UserDao 查找到 User,并将 User 转换为 Spring
Security 可识别 UserDetails。

SecurityConfig

SecurityConfig 继承 WebSecurityConfigurerAdapter,需要定义 Spring Security 配置类。Spring Security
的配置不是本文的重点,配置时只需要注意以下几点:

  1. 将 FingerPrintFilter、FingerPrintProvider 添加进去。
  2. 将 FingerPrintFilter 的执行顺序放置在 SecurityContextPersistenceFilter 之后即可。Spring Security 维护了一个
    Filter 的 list,因此每个 Filter 是有顺序的。
  3. 将 "/api/test" 请求设置为用户验证成功后才允许方问。

配置代码在 configure 函数中,如清单 18 所示:

清单 18. configure
函数

protected void configure(HttpSecurity http) throws Exception {
       http
              .userDetailsService(userDetailsService())
              .addFilterAfter(fingerPrintFilter(), SecurityContextPersistenceFilter.class)
              .authenticationProvider(fingerPrintProvider())
              .authorizeRequests()
              .mvcMatchers(HttpMethod.GET, "/api/test").authenticated()
}

总结

在 Web 时代,用户和应用的耦合度越来越高,应用中存储了大量用户的私密信息。随着各种用户信息泄露事件的爆发,安全成为了 Web 应用重要的一个环。Spring
Security 由于其强大的功能和 Spring Framework 的高度集成,赢得了开发人员的青睐。本文对 Spring Security
的架构设计与核心组件进行了深入浅出的介绍,分析了 Spring Security 在 Web 应用的集成方式,并展示了一个指纹登录的实例。

参考资源

  • 参考 Spring Security 官方文档
    ,了解更多内容。

原文 

http://www.ibm.com/developerworks/cn/web/wa-spring-security-web-application-and-fingerprint-login/index.html?ca=drs-

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » Spring Security 的 Web 应用和指纹登录实践

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址