程序员L札记

V1

2022/10/25阅读:26主题:橙心

跟我一起学Spring Security(二)

前言

由于工作原因,让关注这个系列的朋友们久等了,今天我们来实现另一种登录认证方式--手机短信验证码登录,学完这一篇,希望可以举一反三,实现更多其他登录方式。好了,废话不多说,直接开始。

开发短信验证码登录

开发短信验证码接口

@GetMapping("/code/sms")
public void createSmsCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
    ValidateCode validateCode = generateSmsCode();
    save(validateCode, new ServletWebRequest(request, response));
    send(validateCode, new ServletWebRequest(request, response));
}

private ValidateCode generateSmsCode() {
 //生成6位的验证码
    String code = RandomStringUtils.randomNumeric(6);
    //默认过期时间60秒
    return new ValidateCode(code, 60);
}

保存方法与前一篇的方法一样,同样是保存到session中,send方法调用具体的短信提供商接口即可,这里就不做实现了。可以看到短信验证码的生成逻辑与图片验证码几乎一样,所以此处可以抽象出来,比如通过模版方法和策略模式模式将验证码生成、保存、发送的三个步骤进行改造,其中的三个步骤又可以进一步进行抽象,比如保存,可以提供保存到session、redis等实现。

登录页面

<form action="/authentication/mobile" method="post">
 <table>
  <tr>
   <td>手机号:</td>
   <td><input type="text" name="mobile" value="13012345678"></td>
  </tr>
  <tr>
   <td>短信验证码:</td>
   <td>
    <input type="text" name="smsCode">
    <a href="/code/sms?mobile=13012345678">发送验证码</a>
   </td>
  </tr>
  <tr>
   <td colspan='2'><input name="remember-me" type="checkbox" value="true" />记住我</td>
  </tr>
  <tr>
   <td colspan="2"><button type="submit">登录</button></td>
  </tr>
 </table>
</form>

校验短信验证码并登录

实现原理

开始之前先来简单介绍一下用户名密码登录的原理,以便我们开发短信登录相关功能。首先用户名密码登录会经过UsernamePasswordAuthenticationFilter过滤器,过滤器内部会创建一个未经过认证的UsernamePasswordAuthenticationToken对象,接着会将token传递给AuthenticationManager(认证管理器),AuthenticationManager内部会查找AuthenticationProvider的所有实现类,然后循环检查当前实现类是否支持UsernamePasswordAuthenticationToken类型的token,如果支持就会调用当前的provider,然后AuthenticationProvider会调用我们自定义实现的UserDetailsService,如果根据用户名查找到相关UserDetails信息,那么就会使用未认证的UsernamePasswordAuthenticationToken对象以及UserDetails对象,重新创建一个已认证的Authentication对象并返回。

如果清楚了以上用户名密码的认证原理,那么我们就可以照猫画虎,实现一个手机号短信认证方式。步骤如下:

  1. 实现一个SmsCodeAuthenticationToken
  2. 实现SmsCodeAuthenticationFilter,内部创建一个未认证的SmsCodeAuthenticationToken,并交给AuthenticationManager
  3. 实现一个SmsCodeAuthenticationProvider,仅支持SmsCodeAuthenticationToken类型的token

SmsCodeAuthenticationToken

public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {

 private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

 // ~ Instance fields
 // ================================================================================================

 private final Object principal;

 // ~ Constructors
 // ===================================================================================================

 /**
  * This constructor can be safely used by any code that wishes to create a
  * <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()}
  * will return <code>false</code>.
  *
  */

 public SmsCodeAuthenticationToken(String mobile) {
  super(null);
  this.principal = mobile;
  setAuthenticated(false);
 }

 /**
  * This constructor should only be used by <code>AuthenticationManager</code> or
  * <code>AuthenticationProvider</code> implementations that are satisfied with
  * producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
  * authentication token.
  *
  * @param principal
  * @param authorities
  */

 public SmsCodeAuthenticationToken(Object principal,
   Collection<? extends GrantedAuthority> authorities)
 
{
  super(authorities);
  this.principal = principal;
  super.setAuthenticated(true); // must use super, as we override
 }

 // ~ Methods
 // ========================================================================================================
    @Override
 public Object getCredentials() {
  return null;
 }
    @Override
 public Object getPrincipal() {
  return this.principal;
 }
    @Override
 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");
  }

  super.setAuthenticated(false);
 }

 @Override
 public void eraseCredentials() {
  super.eraseCredentials();
 }
}

代码来源于UsernamePasswordAuthenticationToken,删减掉不相干的属性和代码,请自行做对比。

SmsCodeAuthenticationFilter

public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
 // ~ Static fields/initializers
 // =====================================================================================

 private String mobileParameter = "mobile";
 private boolean postOnly = true;

 // ~ Constructors
 // ===================================================================================================

 public SmsCodeAuthenticationFilter() {
  super(new AntPathRequestMatcher("/authentication/mobile""POST"));
 }

 // ~ Methods
 // ========================================================================================================
    @Override
 public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
   throws AuthenticationException {
  if (postOnly && !request.getMethod().equals("POST")) {
   throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
  }

  String mobile = obtainMobile(request);

  if (mobile == null) {
   mobile = "";
  }

  mobile = mobile.trim();

  SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);

  // Allow subclasses to set the "details" property
  setDetails(request, authRequest);

  return this.getAuthenticationManager().authenticate(authRequest);
 }


 /**
  * 获取手机号
  */
 protected String obtainMobile(HttpServletRequest request) {
  return request.getParameter(mobileParameter);
 }

 /**
  * Provided so that subclasses may configure what is put into the
  * authentication request's details property.
  *
  * @param request
  *            that an authentication request is being created for
  * @param authRequest
  *            the authentication request object that should have its details
  *            set
  */
 protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
  authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
 }

 /**
  * Sets the parameter name which will be used to obtain the username from
  * the login request.
  *
  * @param usernameParameter
  *            the parameter name. Defaults to "username".
  */
 public void setMobileParameter(String usernameParameter) {
  Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
  this.mobileParameter = usernameParameter;
 }


 /**
  * Defines whether only HTTP POST requests will be allowed by this filter.
  * If set to true, and an authentication request is received which is not a
  * POST request, an exception will be raised immediately and authentication
  * will not be attempted. The <tt>unsuccessfulAuthentication()</tt> method
  * will be called as if handling a failed authentication.
  * <p>
  * Defaults to <tt>true</tt> but may be overridden by subclasses.
  */
 public void setPostOnly(boolean postOnly) {
  this.postOnly = postOnly;
 }

 public final String getMobileParameter() {
  return mobileParameter;
 }

}

代码来源于UsernamePasswordAuthenticationFilter,删减掉了一些不想干的代码,请自行做对比。

SmsCodeAuthenticationProvider

/**
 * 短信登录验证逻辑
 * 
 * 由于短信验证码的验证在过滤器里已完成,这里直接读取用户信息即可。
 *
 */
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

 private UserDetailsService userDetailsService;

 /*
  * (non-Javadoc)
  * 
  * @see org.springframework.security.authentication.AuthenticationProvider#
  * authenticate(org.springframework.security.core.Authentication)
  */
 @Override
 public Authentication authenticate(Authentication authentication) throws AuthenticationException {

  SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
  
  UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());

  if (user == null) {
   throw new InternalAuthenticationServiceException("无法获取用户信息");
  }
  
  SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user, user.getAuthorities());
  
  authenticationResult.setDetails(authenticationToken.getDetails());

  return authenticationResult;
 }

 /*
  * (non-Javadoc)
  * 
  * @see org.springframework.security.authentication.AuthenticationProvider#
  * supports(java.lang.Class)
  */
 @Override
 public boolean supports(Class<?> authentication) {
  return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
 }

 public UserDetailsService getUserDetailsService() {
  return userDetailsService;
 }

 public void setUserDetailsService(UserDetailsService userDetailsService) {
  this.userDetailsService = userDetailsService;
 }

}

配置

@Configurable
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Autowired
    private DataSource dataSource;

    @Autowired
    private UserDetailsService userDetailsService;


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        //验证码校验(内部增加短信验证码的校验逻辑,自行实现)
        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        //短信登录
        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        smsCodeAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
        smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);

        http.authenticationProvider(smsCodeAuthenticationProvider)
                .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin(formLogin -> formLogin
                        .loginPage("/login.html")//自定义登录页面
                        .loginProcessingUrl("/doLogin")//自定义登录请求的url,默认/login
                        .successHandler(customAuthenticationSuccessHandler)//自定义登录成功后的处理
                        .failureHandler(customAuthenticationFailureHandler)//自定义登录失败后的处理
                )//开启表单认证
                .rememberMe()
                .tokenRepository(persistentTokenRepository())
                .tokenValiditySeconds(3600)
                .userDetailsService(userDetailsService)
                .and()
                .authorizeRequests(authorize -> authorize
                        .antMatchers("/login.html""/favicon.ico""/code/image""/authentication/mobile").permitAll()//不需要经过身份认证的资源
                        .anyRequest().authenticated()//其他任何请求都需要经过身份认证
                )//授权请求
                .csrf().disable();
        return http.build();
    }

    /**
     * 记住我功能的token存取器配置
     *
     * @return
     */
    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        //启动时创建数据库表(下次启动改为false
        tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }

}

总结

至此已经实现了手机短信认证方式。由于时间有限,可能会存在遗漏的地方,后期会慢慢补充并做更新,请持续关注。

大家晚安!

欢迎关注我的公众号:程序员L札记

更多原创文章,请扫码关注我的微信公众号
更多原创文章,请扫码关注我的微信公众号

分类:

后端

标签:

后端

作者介绍

程序员L札记
V1