SpringSecurity-9- Realize a função de autenticação através de SMS de celular

SpringSecurity-9- Realize a função de autenticação através de SMS de celular

Análise de Processo de SMS Móvel

Ao fazer login com seu número de celular, você não precisa de uma senha para fazer login. Em vez disso, você pode fazer login sem uma senha por meio de um código de verificação por SMS. As etapas específicas são as seguintes:

  1. Envie códigos de verificação para telefones celulares, plataformas de envio de SMS de terceiros, como Alibaba Cloud SMS

  2. Depois que o telefone receber o código de verificação, insira o código de verificação no formulário

  3. Usar filtro personalizado SmsCodeValidateFilter

  4. Depois que a verificação por SMS for aprovada, use o filtro de autenticação de celular personalizado SmsCodeAuthenticationFilter para verificar se o número de celular existe

  5. SmsCodeAuthenticationToken personalizado fornecido para SmsCodeAuthenticationFilter

  6. SmsCodeAuthenticationProvider personalizado fornecido ao AuthenticationManager

  7. Crie um SmsCodeUserDetailsService que consulte as informações do usuário para números de telefone celular e envie-o para SmsCodeAuthenticationProvider

  8. A classe de configuração personalizada SmsCodeSecurityConfig conecta os componentes acima

  9. Adicione SmsCodeSecurityConfig à cadeia de filtros da configuração de segurança LearnSrpingSecurity

Criar interface de envio de SMS


  • Defina a interface de serviço com.security.learn.sms.SmsCodeSend for SMS

código mostrar como abaixo:

public interface SmsCodeSend {
    boolean sendSmsCode(String mobile, String code);
}

复制代码
  • 实现短信发生服务接口com.security.learn.sms.impl.SmsCodeSendImpl代码如下
@Slf4j
public class SmsCodeSendImpl implements SmsCodeSend {
    @Override
    public boolean sendSmsCode(String mobile, String code) {
        String sendCode = String.format("你好你的验证码%s,请勿泄露他人。", code);
        log.info("向手机号" + mobile + "发送的短信为:" + sendCode);
        return true;
    }
}

复制代码

:因为这里是示例所以就没有真正的使用第三方发送短信平台。

  • smsCodeSend注入到容器实现如下
@Configuration
public class Myconfig {
    @Bean
    public PasswordEncoder passwordEncoder(){
        return  new BCryptPasswordEncoder();
    }
    @Bean
    @ConditionalOnMissingBean(SmsCodeSend.class)
    public SmsCodeSend smsCodeSend(){
        return  new SmsCodeSendImpl();
    }
}

复制代码

手机登录页与发送短信验证码

  • 创建SmsController实现短信发送验证码的API,代码如下:
@Controller
public class SmsController {
    public static final String SESSION_KEY = "SESSION_KEY_MOBILE_CODE";
    @RequestMapping("/mobile/page")
    public String toMobilePage(){
        return "login-mobile";
    }
    @Autowired
    private SmsCodeSend smsCodeSend;
    /**
     *生成手机验证码并发送
     * @param request
     * @return
     */
    @RequestMapping("/code/mobile")
    @ResponseBody
    public String smsCode(HttpServletRequest request){
        // 1. 生成一个手机验证码
        String code = RandomStringUtils.randomNumeric(4);
        request.getSession().setAttribute(SESSION_KEY, code);
        String mobile = request.getParameter("mobile");
        smsCodeSend.sendSmsCode(mobile, code);
        return "200";
    }
}

复制代码
  • src\main\resources\templates文件夹下添加login-mobile.html静态页面,具体实现如下
<!--suppress ALL-->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>springboot葵花宝典手机登录</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body >
<div >
    <a href="#">springboot葵花宝典手机登录</a>  <br>
    <a th:href="@{/login/page}" href="login.html" >
        <span>使用密码验证登录</span>
    </a> <br>
    <form th:action="@{/mobile/form}" action="index.html" method="post">
        <span>手机号码</span> <input id="mobile" name="mobile" type="text" class="form-control" placeholder="手机号码"><br>
        <span>验证码</span> <input type="text" name="smsCode" class="form-control" placeholder="验证码">  <a id="sendCode" th:attr="code_url=@{/code/mobile?mobile=}" href="#"> 获取验证码 </a><br>
        <!-- 提示信息, 表达式红线没关系,忽略它 -->
        <div th:if="${param.error}">
            <span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION?.message}" style="color:#ff0000"></span>
        </div>

        <span>记住我</span><input type="checkbox" name="remember-me-test" >  <br>
        <button type="submit" class="btn btn-primary btn-block">登录</button>
    </form>

</div>
<script th:src="@{/plugins/jquery/jquery.min.js}" src="plugins/jquery/jquery.min.js"></script>
<script>
    // 发送验证码
    $("#sendCode").click(function () {
        var mobile = $('#mobile').val().trim();
        if(mobile == '') {
            alert("手机号不能为空");
            return;
        }
        var url = $(this).attr("code_url") + mobile;
        $.get(url, function(data){
            alert(data === "200" ? "发送成功": "发送失败");
        });
    });

</script>
</body>
</html>

复制代码
  • LearnSrpingSecurityconfigure(HttpSecurity http)方法中添加手机免密登录允许的url
  .and().authorizeRequests()
                .antMatchers("/login/page","/code/image","/mobile/page","/code/mobile").permitAll()

复制代码

短信验证码校验过滤器 SmsCodeValidateFilter


短信验证码的校验过滤器,实际上和图片验证过滤器原理一致。都都是继承OncePerRequestFilter实现一个Spring环境下的过滤器。@Component注解不可少其核心校验规则如下:

  • 登录时候手机号码不可为空

  • 登录时手机输入码不可为空

  • 登录时输入的短信验证码必须和“谜底”中的验证码一致

@Component
public class SmsCodeValidateFilter extends OncePerRequestFilter {

    @Autowired
    MyAuthenticationFailureHandler failureHandler;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (request.getRequestURI().equals("/mobile/form")
                && request.getMethod().equalsIgnoreCase("post")) {
            try {
                validate(request);

            }catch (AuthenticationException e){
                failureHandler.onAuthenticationFailure(
                        request,response,e);
                return;
            }
        }
        filterChain.doFilter(request,response);
    }

    private void validate(HttpServletRequest request)  {
        //获取session中的手机验证码
        HttpSession session = request.getSession();
        String sessionCode = (String)request.getSession().getAttribute(SmsController.SESSION_KEY);
        // 获取用户输入的验证码
        String inpuCode = request.getParameter("smsCode");
        //手机号
        String mobileInRequest = request.getParameter("mobile");
        if(StringUtils.isEmpty(mobileInRequest)){
            throw new ValidateCodeException("手机号码不能为空!");
        }
        if(StringUtils.isEmpty(inpuCode)){
            throw new ValidateCodeException("短信验证码不能为空!");
        }
        if(StrUtil.isBlank(sessionCode)){
            throw new ValidateCodeException("短信验证码不存在!");
        }
        if(!sessionCode.equalsIgnoreCase(inpuCode)){
            throw new ValidateCodeException("输入的短信验证码错误!");
        }
        session.removeAttribute(SmsController.SESSION_KEY);
    }
}

复制代码

实现手机认证SmsCodeAuthenticationFilter过滤器

创建com.security.learn.filter.SmsCodeAuthenticationFilter,仿照UsernamePassword AuthenticationFilter进行代码实现,不过将用户名、密码换成手机号进行认证,短信验证码在此部分已经没有用了,因为我们在SmsCodeValidateFilter已经验证过了

public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";
    private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY ;    //请求中携带手机号的参数名称
    private boolean postOnly = true;    //指定当前过滤器是否只处理POST请求

    public SmsCodeAuthenticationFilter() {
        //指定当前过滤器处理的请求
        super(new AntPathRequestMatcher("//mobile/form", "POST"));
    }

    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());
        }
        //从请求中获取手机号码
        String mobile = this.obtainMobile(request);
        if (mobile == null) {
            mobile = "";
        }
        mobile = mobile.trim();
        SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
        this.setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);
    }
    /**
     * 从从请求中获取手机号码
     * @param request
     * @return
     */
    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(this.mobileParameter);
    }
    /**
     * 将请求中的Sessionid和host主句ip放到SmsCodeAuthenticationToken中
     * @param request
     * @param authRequest
     */
    protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }
    public void setMobileParameter(String mobileParameter) {
        Assert.hasText(mobileParameter, "Username parameter must not be empty or null");
        this.mobileParameter = mobileParameter;
    }
    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }
    public final String getMobileParameter() {
        return this.mobileParameter;
    }
}

复制代码

封装手机认证Token  SmsCodeAuthenticationToken

创建com.security.learn.filter.SmsCodeAuthenticationToken,仿照UsernamePasswordAuthenticationToken进行代码实现

public class SmsCodeAuthenticationToken  extends AbstractAuthenticationToken {
    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    //存放认证信息,认证之前存放手机号,认证之后存放登录的用户
    private final Object principal;

    /**
     * 开始认证时,SmsCodeAuthenticationToken 接收的是手机号码, 并且 标识未认证
     * @param mobile
     */
    public SmsCodeAuthenticationToken(String mobile) {
        super(null);
        this.principal = mobile;
        this.setAuthenticated(false);
    }

    /**
     *  当认证通过后,会重新创建一个新的SmsCodeAuthenticationToken,来标识它已经认证通过,
     * @param principal 用户信息
     * @param authorities 用户权限
     */
    public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true);//表示认证通过
    }

    /**
     * 在父类中是一个抽象方法,所以要实现, 但是它是密码,而当前不需要,则直接返回null
     * @return
     */
    public Object getCredentials() {
        return null;
    }

    /**
     * 手机号获取
     * @return
     */
    public Object getPrincipal() {
        return this.principal;
    }

    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);
    }

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

复制代码

手机认证提供者 SmsCodeAuthenticationProvider

创建com.security.learn.filter.SmsCodeAuthenticationProvider,提供给底层的ProviderManager代码实现如下

public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
    @Autowired
    @Qualifier("smsCodeUserDetailsService")
    private UserDetailsService userDetailsService;
    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }
    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
    /**
     * 处理认证:
     * 1. 通过 手机号 去数据库查询用户信息(UserDeatilsService)
     * 2. 再重新构建认证信息
     * @param authentication
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        //利用UserDetailsService获取用户信息,拿到用户信息后重新组装一个已认证的Authentication

        SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken)authentication;
        UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());  //根据手机号码拿到用户信息
        if(user == null){
            throw new AuthenticationServiceException("无法获取用户信息");
        }
        SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user,user.getAuthorities());
        authenticationResult.setDetails(authenticationToken.getDetails());
        return authenticationResult;
    }

    /**
     * AuthenticationManager挑选一个AuthenticationProvider
     * 来处理传入进来的Token就是根据supports方法来判断的
     * @param aClass
     * @return
     */
    @Override
    public boolean supports(Class<?> aClass) {
        return SmsCodeAuthenticationToken.class.isAssignableFrom(aClass);
    }
}

复制代码

手机号获取用户信息 SmsCodeUserDetailsService

创建com.security.learn.impl.SmsCodeUserDetailsService类,不要注入PasswordEncoder

@Slf4j
@Component("smsCodeUserDetailsService")
public class SmsCodeUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {
        log.info("请求的手机号是:" + mobile);
        return new User(mobile, "", true, true, true, true, AuthorityUtils.commaSeparatedStringToAuthorityList("ADMIN"));
    }
}

复制代码

因为测试就没有去数据库中获取手机号

自定义管理认证配置 SmsCodeSecurityConfig

最后我们将以上实现进行组装,并将以上接口实现以配置的方式告知Spring Security。因为配置代码比较多,所以我们单独抽取一个关于短信验证码的配置类SmsCodeSecurityConfig,继承自SecurityConfigurerAdapter。将上面定义的组件绑定起来,添加到容器中:

注意添加@Component注解

@Component
public class SmsCodeSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    
    @Autowired
    @Qualifier("smsCodeUserDetailsService")
    private SmsCodeUserDetailsService smsCodeUserDetailsService;

    @Resource
    private SmsCodeValidateFilter  smsCodeValidateFilter;

    @Override
    public void configure(HttpSecurity http) throws Exception {

        //创建手机校验过滤器实例
        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        //接收 AuthenticationManager 认证管理器
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        //处理成功handler
        //smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler);
        //处理失败handler
        //smsCodeAuthenticationFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
        smsCodeAuthenticationFilter.setRememberMeServices(http.getSharedObject(RememberMeServices.class));
        // 获取验证码提供者
        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
        smsCodeAuthenticationProvider.setUserDetailsService(smsCodeUserDetailsService);

        //在用户密码过滤器前面加入短信验证码校验过滤器
        http.addFilterBefore(smsCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
        //在用户密码过滤器后面加入短信验证码认证授权过滤器
        http.authenticationProvider(smsCodeAuthenticationProvider)
                .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

复制代码

绑定到安全配置 LearnSrpingSecurity

  1. . 向 LearnSrpingSecurity中注入 SmsCodeValidateFilter和 SmsCodeSecurityConfig实例

  2. 将 SmsCodeValidateFilter实例添加到 UsernamePasswordAuthenticationFilter 前面

  http.csrf().disable() //禁用跨站csrf攻击防御,后面的章节会专门讲解
                //.addFilterBefore(codeValidateFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(smsCodeValidateFilter, UsernamePasswordAuthenticationFilter.class)

复制代码
  1. 在 LearnSrpingSecurity#confifigure(HttpSecurity http) 方法体最后调用 apply 添加 SmsCodeSecurityConfig
                .and()
                .apply(smsCodeSecurityConfig)

复制代码

具体实现如图

实现手机登录RememberMe功能


实现分析

  1. UsernamePasswordAuthenticationFilter过滤器中有一个RememberMeServices引用,它的父类AbstractAuthenticationProcessingFilter,提供提供的 setRememberMeServices方法。

  2. 而在实现手机短信验证码登录时,我们自定了一个   MobileAuthenticationFilter   也一样的继承了AbstractAuthenticationProcessingFilter 它,我们只要向其 setRememberMeServices 方法手动注入一 个 RememberMeServices 实例即可。

代码实现

  1. com.security.learn.config.SmsCodeSecurityConfig中向SmsCodeAuthenticationFilter中注入RememberMeServices实例
 smsCodeAuthenticationFilter.setRememberMeServices(http.getSharedObject(RememberMeServices.class));

复制代码

  • 检查 记住我 的 input 标签的 name="remember-me-test"
<span>记住我</span><input type="checkbox" name="remember-me-test" >  <br>

复制代码
  • rememberMeParameter设置from表单“自动登录”勾选框的参数名称。如果这里改了,from表单中checkbox的name属性要对应的更改。如果不设置默认是remember-me。

  • rememberMeCookieName设置了保存在浏览器端的cookie的名称,如果不设置默认也是remember-me。如下图中查看浏览器的cookie。

测试


重启项目,访问 http://localhost:8888/mobile/page输入手机号与验证码, 勾选 记住我  , 点击登录

查看数据库中中 persistent_logins 表的记录

关闭浏览器, 再重新打开浏览器访问http://localhost:8888 , 发现会跳转回用户名密码登录页,而正常应该勾选了 记住我  , 这一步应该是可以正常访问的.

错误原因

数据库中 username 为 手机号 1333383XXXX, 当你访问http://localhost:8888默认RememberMeServices 是调

用 CustomUserDetailsService  通过用户名查询, 而 当前在 CustomUserDetailsService 判断了用户名为 admin才通

过认证, 而此时传入的用户名是 1333383XXXX, 所以查询不到 1333383XXXX用户数据

错误解决方式

数据库中的 persistent_logins 表为什么存储的是手机号?原因是当前在 SmsCodeUserDetailsService中返回的 User 对象中的 username 属性设置的是手机号 mobile,而应该设置这个手机号所对应的那个用户名. 比如当前username 的值

@Slf4j
@Component("smsCodeUserDetailsService")
public class SmsCodeUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {
        log.info("请求的手机号是:" + mobile);

        return new User("admin", "", true, true, true, true, AuthorityUtils.commaSeparatedStringToAuthorityList("ADMIN"));
    }
}

复制代码

:我们这里实际上是写为固定admin了实际上需要通过数据库根据手机号获取用户信息。

关闭浏览再打开访问http://localhost:8888就无需手动登录认证了。因为默认采用的 CustomUserDetailsService 查询可查询到用户名为 admin 的信息,即认证通过

如果您觉得本文不错,欢迎关注,点赞,收藏支持,您的关注是我坚持的动力!

原创不易,转载请注明出处,感谢支持!如果本文对您有用,欢迎转发分享!

Acho que você gosta

Origin juejin.im/post/7079947930106822669
Recomendado
Clasificación