距离上一次更新,不知不觉已经过去了半个月了,人真的是不能放松,一放松就肆意妄为了。希望这个月内可以把 SpringSecurity 系列更新完毕吧,加油!。( 这里是将我之前在其他博客上的文章搬运到自建的博客上,所以时间上会有点差异,具体时间以代码上时间为准。)
OK,言归正传上一章我们利用 SpringSecurity 提供的一些可选配置,实现了自定义表单登录。但是在我们的日常需求中,仅仅是表单登录时满足不了的。所以这一章,我给大家带来 SpringSecurity 下自定义登录方式的示例。
首先我们选定我们的自定义登录方式,这里我们选择手机短信登录。显而易见,SpringSecurity 并没有给我们提供手机短信登录的简单配置集成方式,所以需要我们自己来进行实现。
短信认证组成分析
我们先来分析一波,手机短信登录我们可以分为两个部分:
手机验证码校验
手机验证码校验应该是一个复用模块,因为不光登录可能会用到,注册、绑定等很多场景也都可能用到,并且这一块和 SpringSecurity 关系不大,我们放到后面,将其专门开发成一个 Lib。
手机号登录
通过了手机验证码校验,其实就是一个手机号登录了,按用户的手机号去数据库查询。所以我们现在主要完成第二块,手机号登录。
通过源码分析 SpringSecurity 通用认证流程
要自定义手机号登录,我们这里必须分析一下 SpringSecurity 的认证流程 ,具体源码在后面的章节我会带着大家去详细看一下。这里我们先来分析一下 SpringSecurity 的可能认证流程,我们前面的章节已经可以使用表单登录了,那么我们就以表单登录的方式来跟踪一下源码,分析出认证流程,回忆一下我们之前做了那些事:
我们指明了登录方式为 formLogin
我们通过设置配置,自定义认证路径
我们自定义了 UserDetailService 从数据库中查询用户信息
我们自定义了认证成功或失败处理器
然后我们来猜测一下可能的认证流程
用户发起认证请求,服务端从请求中取出参数
去数据库按参数进行查询,然后进行校验
最后做认证结果处理。
我们从 IDEA 点击查看 formLogin 方法
HttpSecurity 类
1 2 3 4 public FormLoginConfigurer<HttpSecurity> formLogin () throws Exception { return (FormLoginConfigurer)this .getOrApply(new FormLoginConfigurer()); }
继续点击 FormLoginConfigurer 类进行查看,发现在 FormLoginConfigurer 的构造函数中创建了一个 UsernamePasswordAuthenticationFilter,并且设置了表单登录的参数名。
FormLoginConfigurer 类
1 2 3 4 5 public FormLoginConfigurer () { super (new UsernamePasswordAuthenticationFilter(), (String)null ); this .usernameParameter("username" ); this .passwordParameter("password" ); }
我们继续进入 UsernamePasswordAuthenticationFilter,我们发现这是一个过滤器,其次在它的构造函数里面指定了 /login 和 Post 。(结合之前我们配置时说的,默认登录地址是 login + Post ),我们猜测这里是设置了拦截的 Url 和 Method ,那么这个 Filter 应该就是认证的入口
UsernamePasswordAuthenticationFilter 类
1 2 3 public UsernamePasswordAuthenticationFilter () { super (new AntPathRequestMatcher("/login" , "POST" )); }
我们继续看 Filter 的 处理方法,可以出在这个方法里面做了三步处理:
从请求中取出了表单参数
将参数封装到了 UsernamePasswordAuthenticationToken 中
使用getAuthenticationManager().authenticate(authRequest); 进行认证。
getAuthenticationManager 获取到的是一个 AuthenticationManager 对象,实际上是使用 AuthenticationManager 的 authenticate 方法进行认证。
UsernamePasswordAuthenticationFilter 类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public Authentication attemptAuthentication (HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this .postOnly && !request.getMethod().equals("POST" )) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } else { String username = this .obtainUsername(request); String password = this .obtainPassword(request); if (username == null ) { username = "" ; } if (password == null ) { password = "" ; } username = username.trim(); UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); this .setDetails(request, authRequest); return this .getAuthenticationManager().authenticate(authRequest); } }
我们继续进入 authenticate 方法,发现其是一个接口方法,有很多实现类。没办法,我们只好将应用启动,进行 debug 断点跟踪。
1 2 3 public interface AuthenticationManager { Authentication authenticate (Authentication var1) throws AuthenticationException ; }
经过断点跟踪,我们发现实际上调用的是 ProviderManager 的 authenticate 方法,我们发现在该方法中,获取所有的 Providers,然后遍历,找出与封装的 Token 匹配的 Provider,调用其 authenticate 方法。
ProviderManager 类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 public Authentication authenticate (Authentication authentication) throws AuthenticationException { Class<? extends Authentication> toTest = authentication.getClass(); AuthenticationException lastException = null ; Authentication result = null ; boolean debug = logger.isDebugEnabled(); for (AuthenticationProvider provider : getProviders()) { if (!provider.supports(toTest)) { continue ; } if (debug) { logger.debug("Authentication attempt using " + provider.getClass().getName()); } try { result = provider.authenticate(authentication); if (result != null ) { copyDetails(authentication, result); break ; } } catch (AccountStatusException e) { prepareException(e, authentication); throw e; } catch (InternalAuthenticationServiceException e) { prepareException(e, authentication); throw e; } catch (AuthenticationException e) { lastException = e; } } if (result == null && parent != null ) { try { result = parent.authenticate(authentication); } catch (ProviderNotFoundException e) { } catch (AuthenticationException e) { lastException = e; } } if (result != null ) { if (eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) { ((CredentialsContainer) result).eraseCredentials(); } eventPublisher.publishAuthenticationSuccess(result); return result; } if (lastException == null ) { lastException = new ProviderNotFoundException(messages.getMessage( "ProviderManager.providerNotFound" , new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}" )); } prepareException(lastException, authentication); throw lastException; }
我们继续追踪 Provider 的 authenticate 方法,进入AbstractUserDetailsAuthenticationProvider 的 authenticate 方法,我们重点关注一下 retrieveUser 方法。
AbstractUserDetailsAuthenticationProvider 类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 public Authentication authenticate (Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, messages.getMessage( "AbstractUserDetailsAuthenticationProvider.onlySupports" , "Only UsernamePasswordAuthenticationToken is supported" )); String username = (authentication.getPrincipal() == null ) ? "NONE_PROVIDED" : authentication.getName(); boolean cacheWasUsed = true ; UserDetails user = this .userCache.getUserFromCache(username); if (user == null ) { cacheWasUsed = false ; try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } catch (UsernameNotFoundException notFound) { logger.debug("User '" + username + "' not found" ); if (hideUserNotFoundExceptions) { throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials" , "Bad credentials" )); } else { throw notFound; } } Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract" ); } try { preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } catch (AuthenticationException exception) { if (cacheWasUsed) { cacheWasUsed = false ; user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } else { throw exception; } } postAuthenticationChecks.check(user); if (!cacheWasUsed) { this .userCache.putUserInCache(user); } Object principalToReturn = user; if (forcePrincipalAsString) { principalToReturn = user.getUsername(); } return createSuccessAuthentication(principalToReturn, authentication, user); }
我们发现 retrieveUser 方法是一个抽象方法,具体实现应该在子类中,继续追踪,发现实现在 DaoAuthenticationProvider 中,在 retrieveUser 方法中调用 UserDetailService 的 loadUserByUsername 方法,到了这里,大概的流程就和我们上一章的配置对上了。
PS: UserDetailService 在系统中有多个实现,这里会使用哪个要看实际情况与设置,这个后面有机会说一下。
AbstractUserDetailAuthenticationProvider 类
1 2 3 protected abstract UserDetails retrieveUser (String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException ;
DaoAuthenticationProcider 类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 protected final UserDetails retrieveUser (String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { UserDetails loadedUser; try { loadedUser = this .getUserDetailsService().loadUserByUsername(username); } catch (UsernameNotFoundException notFound) { if (authentication.getCredentials() != null ) { String presentedPassword = authentication.getCredentials().toString(); passwordEncoder.isPasswordValid(userNotFoundEncodedPassword, presentedPassword, null ); } throw notFound; } catch (Exception repositoryProblem) { throw new InternalAuthenticationServiceException( repositoryProblem.getMessage(), repositoryProblem); } if (loadedUser == null ) { throw new InternalAuthenticationServiceException( "UserDetailsService returned null, which is an interface contract violation" ); } return loadedUser; }
SpringSecurity 认证流程总结:
我们来总结一下整个认证流程,通过过滤器获取到请求参数,封装 Token,调用 ProviderManager 的 authenticate 方法,该方法实际上调用的 ProviderManager 管理的 Provider (认证逻辑的实现类) 的 authenticate 方法,最后调用 UserDetailService 去获取用户的信息。
首先通过 Filter 拦截用户请求,获取到参数
将参数封装成 Token
调用 AuthenticationManager 的 authenticated 方法。这里 AuthenticationManager 是接口,实际上调用的是 ProviderManager 的 authenticated 方法。从名字我们可以猜测出 ProviderManager 管理了很多 Provider
在 ProviderManager 的 authenticated 方法中,获取所有 Provider,遍历,按 Token 匹配,调用匹配到的 Provider 的 authentication 方法(这里表单登录实际上调用的是 DaoAuthenticationProvider 的方法)
最终调用的是 UserDetailService 的 loadUserByUsername 方法
查询出用户信息后,进行校验
校验通过后,发布认证成功信息。如果认证失败,会抛出异常,最终也会发布认证失败信息。
分析我们自定义短信认证需要实现的操作
自定义一个 Filter 用来拦截手机号登陆
自定义一个 Token,用来装载用户的认证信息与认证后的结果信息
自定义一个 Provider 的实现类,实现核心的认证逻辑
**自定义一个 UserDetailService 的实现,根据手机号查询用户信息 **
最终把上面这些自定义的类作为配置,加入到 SpringSecurity 的校验流程中去
在实际的业务场景中,这里还需要将验证码校验模块放在上面的自定义 Filter 之前,只有验证码校验通过,才能访问短信认证 Filter
OK,接下来我们一步一步来实现:
自定义 SmsCodeAuthenticationToken
继承 AbstractAuthenticationToken 类,父类里面主要三个属性
权限集合 ( Collection<GrantedAuthority )
客户端信息( Object detail )
是否通过认证( authenticated )
自己实现的 Token 在此基础上按照认证方式进行扩展,比如如果是表单登录,需要添加用户名、密码等。我们这里是短信验证码认证,只需要手机号即可。
Token 主要在认证流程中装载数据。
下面单参数的构造方法,传递一个 mobile,是认证前用来存储认证参数的,此时默认将 authenticated 置为 false
双参数的构造方法,是用来状态获取到的用户信息,此时默认将 authenticate 置为 true,但是并不代表当前认证已经通过了。因为可能后面还有密码校验(表单登录时)、账号状态校验等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken { private final Object principal; public SmsCodeAuthenticationToken (Object mobile) { super ((Collection)null ); this .principal = mobile; this .setAuthenticated(false ); } public SmsCodeAuthenticationToken (Object principal, Collection<? extends GrantedAuthority> authorities) { super (authorities); this .principal = principal; super .setAuthenticated(true ); } public void setAuthenticated (boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead" ); } else { super .setAuthenticated(false ); } } public void eraseCredentials () { super .eraseCredentials(); } @Override public Object getCredentials () { return null ; } public Object getPrincipal () { return this .principal; } }
自定义 SmsCodeAuthenticationFilter
主要参考 UsernamePasswordAuthenticationFilter 来实现自定义 Filter。在 Filter 中主要要做的事情有以下几点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter { private String mobileParameter = "mobile" ; private boolean postOnly = true ; protected SmsCodeAuthenticationFilter () { super (new AntPathRequestMatcher("/authentication/mobile" , "POST" )); } @Override public Authentication attemptAuthentication (HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { if (this .postOnly && !request.getMethod().equals("POST" )) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } else { String mobile = obtainMobile(request); if (mobile == null ){ mobile = "" ; } mobile = mobile.trim(); SmsCodeAuthenticationToken token = new SmsCodeAuthenticationToken(mobile); this .setDetails(request, token); return this .getAuthenticationManager().authenticate(token); } } protected String obtainMobile (HttpServletRequest request) { return request.getParameter(mobileParameter); } protected void setDetails (HttpServletRequest request, SmsCodeAuthenticationToken authRequest) { authRequest.setDetails(this .authenticationDetailsSource.buildDetails(request)); } public String getMobileParameter () { return mobileParameter; } public void setMobileParameter (String mobileParameter) { Assert.hasText(mobileParameter, "mobileParameter parameter must not be empty or null" ); this .mobileParameter = mobileParameter; } public void setPostOnly (boolean postOnly) { this .postOnly = postOnly; } }
自定义 SmsCodeAuthenticationProvider
SmsCodeAuthenticationProvider 实现 AuthenticationProvider 接口,其中有两个方法:
authenticate: 主要是认证逻辑实现
在 authenticate 方法中主要的逻辑
从 token 取出参数,调用 UserDetailService 进行查询用户信息。UserDetailService 需要我们根据不同的业务实现不同的实现类,去数据库做不同的查询操作。
使用查询出的用户信息构造新的 SmsCodeAuthenticationToken
如果是表单登录,还要使用 PasswordEncoder 进行密码校验
如果系统有设置账号冻结相关设置,这里可以进行校验(按取出的用户信息)
最后返回 token。(如果返回的 result 不为 null,最后回去做密码擦除等操作,然后调用登录成功处理。)
supports: 对 authenticate 的 参数进行校验,与 Provider 对应的 Token 进行比较,看是否是其子类或子接口。
PS: 这里注明一下,短信验证码校验应该在 SmsCodeAuthenticationFilter 之前就被校验了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 public class SmsCodeAuthenticationProvider implements AuthenticationProvider { private UserDetailsService userDetailsService; @Override public Authentication authenticate (Authentication authentication) throws AuthenticationException { SmsCodeAuthenticationToken token = (SmsCodeAuthenticationToken) authentication; UserDetails userDetails = userDetailsService.loadUserByUsername((String) token.getPrincipal()); if (userDetails == null ){ throw new InternalAuthenticationServiceException("未找到对应的用户信息!" ); } SmsCodeAuthenticationToken authenticationToken = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities()); authenticationToken.setDetails(token.getDetails()); return authenticationToken; } @Override public boolean supports (Class<?> aClass) { return SmsCodeAuthenticationToken.class.isAssignableFrom(aClass); } public void setUserDetailsService (UserDetailsService userDetailsService) { this .userDetailsService = userDetailsService; } }
自定义 UserDetailService
重写 loadUserByUsername 方法,按手机号查询。在实际开发中,这里可以提供一个默认缺省实现,真正的实现交给业务开发人员去实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Component ("mobileUserDetailService" )public class MobileUserDetailService implements UserDetailsService { @Override public UserDetails loadUserByUsername (String mobile) throws UsernameNotFoundException { return new User("4000368163" , "123" , true , true , true , true , AuthorityUtils.commaSeparatedStringToAuthorityList("admin" )); } }
自定义 SmsCodeAuthenticationSecurityConfig
经过上面的几步准备,现在万事俱备,只欠东风。我们只需要将 Filter 和 Provider 添加到 SpringSecurity 的认证链路当中 ( 就可以召唤神龙了 ) 即可。
继承 SecurityConfigurerAdapter 类,重写该类中的 configure(HttpSecurity) 方法。(后面源码分析时,会分析这个类是怎样作用于配置的)
在 configure 方法中,首先初始化自定义的 Filter 和 Provider,最后使用 HttpSecurity 进行设置添加
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 @Component public class SmsCodeAuthenticationConfig extends SecurityConfigurerAdapter <DefaultSecurityFilterChain , HttpSecurity > { @Autowired private AuthenticationSuccessHandler successHandler; @Autowired private AuthenticationFailureHandler failureHandler; @Autowired private UserDetailsService mobileUserDetailService; @Override public void configure (HttpSecurity builder) throws Exception { SmsCodeAuthenticationFilter filter = new SmsCodeAuthenticationFilter(); filter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class)); filter.setAuthenticationSuccessHandler(successHandler); filter.setAuthenticationFailureHandler(failureHandler); SmsCodeAuthenticationProvider provider = new SmsCodeAuthenticationProvider(); provider.setUserDetailsService(mobileUserDetailService); builder.authenticationProvider(provider).addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class); } }
最后,将自定义的 config 添加到配置中,主要使用 apply 方法将我们自定义的 config 加入到 SpringSecurity 中,同时设置手机登录地址访问不需要认证 ,不然就没法使用了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Autowired private SmsCodeAuthenticationConfig smsCodeAuthenticationConfig; @Override protected void configure (HttpSecurity http) throws Exception { http.apply(smsCodeAuthenticationConfig) .and() .formLogin() .loginProcessingUrl("/authentication/form" ) .successHandler(authenticationSuccessHandler) .failureHandler(authenticationFailureHandler) .and() .authorizeRequests() .antMatchers("/authentication/mobile" ).permitAll() .antMatchers( securityProperties.getBrowser().getSignUpUrl()).permitAll() .anyRequest() .authenticated() .and() .csrf() .disable(); }
然后启动服务,因为是 post 请求,我们打开 postman 进行模拟,这里我对DefaultAuthenticationSuccessHandler 做了一下处理,使其返回 principal.toString()
如图所示,证明我们配置的手机号认证流程已经生效了。
同理,除了手机号的自定义登录,我们还可以自定义其他的登录方式,比如微信公众号开发中,我们需要使用用户的 OpenId 来登录,就可以按这个模式来处理 ( 最终更新完后,我会将代码实现上传到 Github 上,到时候会包含这个 weixin openId 登录,这里大家感兴趣的话,可以自己先实现以下)。
这里额外说明一下,QQ 登录 、微信登录 和微信公众号内部登录 虽然都走的 OAuth2 协议,但是又有点不一样,属于纯纯的第三方登录,要使用 SpringSocial + OAuth2 来开发。这个我会放到后面几章去讲解。
下一章的话,会带大家处理一下在 SpringSecurity 下的 Session 管理与登出。
To Be Continue…