Spring Security05--手机验证码登录

上一篇:https://blog.csdn.net/fengxianaa/article/details/124717243

1. 手机验证码登录

账号密码是最常见的登录方式,但是现在的登录多种多样:手机验证码、二维码、第三方授权等等

下面模仿账号密码登录,新增一下手机验证码登录

1. 获取手机验证码

修改 SecController 增加:

@GetMapping("/phone/code")
public String phoneCode(HttpSession session) throws IOException {
    //验证码配置
    Properties properties = new Properties();
    properties.setProperty("kaptcha.image.width", "150");
    properties.setProperty("kaptcha.image.height", "50");
    properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
    properties.setProperty("kaptcha.textproducer.char.length", "4");
    Config config = new Config(properties);
    DefaultKaptcha kaptcha = new DefaultKaptcha();
    kaptcha.setConfig(config);

    //生成验证码
    String code = kaptcha.createText();
    session.setAttribute("phoneNum", code);
    return code;
}

2. PhoneNumAuthenticationToken

用户名密码登录用的是 UsernamePasswordAuthenticationToken,继承 AbstractAuthenticationToken

我们新建 PhoneNumAuthenticationToken 继承 AbstractAuthenticationToken

/**
 * 模仿 UsernamePasswordAuthenticationToken
 * 用来封装前端传过来的手机号、验证码
 */
public class PhoneNumAuthenticationToken extends AbstractAuthenticationToken {

    private final Object phone;//手机号

    private Object num;//验证码


    public PhoneNumAuthenticationToken(Object phone, Object num) {
        super(null);
        this.phone = phone;
        this.num = num;
        setAuthenticated(false);
    }

    public PhoneNumAuthenticationToken(Object phone, Object num, Collection authorities) {
        super(authorities);
        this.phone = phone;
        this.num = num;
        super.setAuthenticated(true); // must use super, as we override
    }

    @Override
    public Object getCredentials() {
        return num;
    }

    @Override
    public Object getPrincipal() {
        return phone;
    }
}

3. PhoneNumAuthenticationFilter

之前的 UsernamePasswordAuthenticationFilter 拦截的是 /user/login 请求,从json中获取用户名、密码

参考 UsernamePasswordAuthenticationFilter 写一个过滤器,拦截短信登录接口/phone/login

新建 PhoneNumAuthenticationFilter 继承 AbstractAuthenticationProcessingFilter

/**
 * 模仿 UsernamePasswordAuthenticationFilter 获取前端传递的 手机号、验证码
 */
public class PhoneNumAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    // 表示这个 Filter 拦截 /phone/login 接口
    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER =
            new AntPathRequestMatcher("/phone/login", "POST");

    // 参数名
    private String phoneParameter = "phone";
    private String numParameter = "num";


    public PhoneNumAuthenticationFilter() {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
    }

    /**
     * 用来获取前端传递的手机号和验证码,然后调用 authenticate 方法进行认证
     * @param request
     * @param response
     * @return
     * @throws AuthenticationException
     * @throws IOException
     * @throws ServletException
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        if (!"POST".equals(request.getMethod())) {
            throw new AuthenticationServiceException("请求方式有误: " + request.getMethod());
        }
        //如果请求的参数格式不是json,直接异常
        if (!request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)){
            throw new AuthenticationServiceException("参数不是json:" + request.getMethod());
        }
        // 用户以json的形式传参的情况下
        String phone = null;
        String num = null;
        try {
            Map map = JSONObject.parseObject(request.getInputStream(),Map.class);
            phone = map.get(phoneParameter);
            num = map.get(numParameter);
        } catch (IOException e) {
            throw new AuthenticationServiceException("参数不对:" + request.getMethod());
        }

        if (phone == null) {
            phone = "";
        }
        if (num == null) {
            num = "";
        }

        phone = phone.trim();
        // 封装手机号、验证码,后面框架会从中拿到 手机号, 调用我们的 LoginPhoneService 获取用户
        PhoneNumAuthenticationToken authRequest
                = new PhoneNumAuthenticationToken(phone, num);
        //设置ip、sessionId信息
        setDetails(request,authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    protected void setDetails(HttpServletRequest request, PhoneNumAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }
}

4. LoginPhoneService

新建 LoginPhoneService

@Component
public class LoginPhoneService implements UserDetailsService {

    @Autowired
    private UserService userService;

    /**
     * 根据手机号查询用户对象
     * @param phone 前端传的手机号
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String phone) throws UsernameNotFoundException {
        // 从数据库查询用户
        User user = userService.getByPhone(phone);
        if(user == null){
            return null;
        }

        // 把用户信息封装到一个 userdetails 对象中,UserDetails是一个接口,LoginUser实现了这个接口
        LoginUser loginUser = new LoginUser();
        loginUser.setUser(user);
        return loginUser;
    }
}

注意,这里需要修改数据库的user表,增加 phone 字段

5. PhoneAuthenticationProvider

之前说过:this.getAuthenticationManager().authenticate(authRequest); 这句代码,其中的 authenticate 方法封装了具体的用户名、密码热证逻辑,其实里面是调用了 DaoAuthenticationProvider 的 authenticate 方法

Spring Security05--手机验证码登录_第1张图片

用户登录的方式有很多种,每一种都有特定的 Provider 负责处理,

DaoAuthenticationProvider 就是负责验证用户名、密码这种方式的登录

我们的手机号、验证码登录,需要自己创建一个 Provider

新建 PhoneAuthenticationProvider 实现 AuthenticationProvider 接口,主要实现 authenticate 方法,写我们自己的认证逻辑

/**
 * 主要实现 authenticate 方法,写我们自己的认证逻辑
 */
@Component
public class PhoneAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private LoginPhoneService loginPhoneService;

    /**
     * 手机号、验证码的认证逻辑
     * @param authentication 其实就是我们封装的 PhoneNumAuthenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        PhoneNumAuthenticationToken token = (PhoneNumAuthenticationToken) authentication;
        String phone = (String) authentication.getPrincipal();// 获取手机号
        String num = (String) authentication.getCredentials(); // 获取输入的验证码
        // 1. 从 session 中获取验证码
        HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String phoneNum = (String) req.getSession().getAttribute("phoneNum");
        if (!StringUtils.hasText(phoneNum)) {
            throw new BadCredentialsException("验证码已经过期,请重新发送验证码");
        }
        if (!phoneNum.equals(num)) {
            throw new BadCredentialsException("验证码不正确");
        }
        // 2. 根据手机号查询用户信息
        LoginUser loginUser = (LoginUser) loginPhoneService.loadUserByUsername(phone);
        if (loginUser == null) {
            throw new BadCredentialsException("用户不存在,请注册");
        }
        // 3. 把用户封装到 PhoneNumAuthenticationToken 中,
        // 后面就可以使用 SecurityContextHolder.getContext().getAuthentication(); 获取当前登陆用户信息
        PhoneNumAuthenticationToken authenticationResult = new PhoneNumAuthenticationToken(loginUser, num, loginUser.getAuthorities());
        authenticationResult.setDetails(token.getDetails());
        return authenticationResult;
    }

    /**
     * 判断是上面 authenticate 方法的 authentication 参数,是哪种类型
     * Authentication 是个接口,实现类有很多,目前我们最熟悉的就是 PhoneNumAuthenticationToken、UsernamePasswordAuthenticationToken
     * 很明显,我们只支持 PhoneNumAuthenticationToken,因为它封装的是手机号、验证码
     * @param authentication
     * @return
     */
    @Override
    public boolean supports(Class authentication) {
        // 如果参数是 PhoneNumAuthenticationToken 类型,返回true
        return (PhoneNumAuthenticationToken.class.isAssignableFrom(authentication));
    }
}

6. 配置 SecurityConfig

下面最重要的,把上面的东西配置到 SecurityConfig 中,让其生效

@Bean
public PhoneNumAuthenticationFilter phoneNumAuthenticationFilter() throws Exception {
    PhoneNumAuthenticationFilter filter = new PhoneNumAuthenticationFilter();
    filter.setAuthenticationManager(authenticationManagerBean());//认证使用
    //设置登陆成功返回值是json
    filter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() {
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
            response.setContentType("application/json;charset=utf-8");
            PrintWriter out = response.getWriter();
            out.write(JSONObject.toJSONString(authentication));
        }
    });
    //设置登陆失败返回值是json
    filter.setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
        @Override
        public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
            response.setContentType("application/json;charset=utf-8");
            PrintWriter out = response.getWriter();
            Map map = new HashMap<>();
            map.put("errMsg", "手机登陆失败:"+ exception.getMessage());
            out.write(JSONObject.toJSONString(map));
            out.flush();
            out.close();
        }
    });
    filter.setFilterProcessesUrl("/phone/login");//其实这里不用设置,在 PhoneNumAuthenticationFilter 我们已经定义了一个静态变量
    return filter;
}

@Autowired
private LoginUserService loginUserService;
/**
     * DaoAuthenticationProvider 是默认做账户密码认证的,现在有两种登录方式,手机号和账户密码
     * 如果不在这里声明,账户密码登录不能用
     * @return
     */
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    //对默认的UserDetailsService进行覆盖
    authenticationProvider.setUserDetailsService(loginUserService);
    authenticationProvider.setPasswordEncoder(passwordEncoder());
    return authenticationProvider;
}
@Autowired
private PhoneAuthenticationProvider phoneAuthenticationProvider;
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        // /phone/code 请求不用登陆
        .antMatchers("/code","/phone/code").permitAll()
        .anyRequest().authenticated()
        .and().csrf().disable();

    http.addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
        .authenticationProvider(daoAuthenticationProvider());//把账户密码验证加进去

    //把 手机号认证过滤器 加到拦截器链中
    http.addFilterAfter(phoneNumAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
        .authenticationProvider(phoneAuthenticationProvider);//把验证逻辑加进去
}

7. 测试

不登录访问 localhost:8080/sec

Spring Security05--手机验证码登录_第2张图片

获取手机验证码

Spring Security05--手机验证码登录_第3张图片

输入错误的验证码

Spring Security05--手机验证码登录_第4张图片

输入正确的,登录成功

Spring Security05--手机验证码登录_第5张图片

8. 优化

其实上面的代码可以优化,根据上面的代码逻辑,我们是先根据手机号拿到用户后,再比较验证码是否正确

根据我们之前账户密码登录的经验,比较验证码是否正确的代码完全可以放到 PhoneNumAuthenticationFilter 中

但是为了模仿账号密码登录的这个过程,我并没有那样做

你可能感兴趣的:(spring,java,后端)