『Spring Security』(五) 用户名密码+验证码登陆(使用 json)

2,231 阅读4分钟

上一节写到,使用 Form-data 表单提交,登陆请求。这一节,我们改造成 json 格式的请求,并且添加登陆验证码参数。

需求描述

登陆时传递三个字段:usernamepasswordcommonLoginVerifyCode

验证码错误,直接返回登陆失败。

解决方案查询

有问题,先找官方文档看看。查阅文档,其针对常见的问题,给出了一些建议。

image-20201114092644191

这打眼一瞅,第一条貌似有点符合啊。我们也是需要添加个自定义参数(验证码)。

image-20201114093142419

提交的登录信息由UsernamePasswordAuthenticationFilter的实例处理,所以我们可以在这里处理登陆请求的参数。

一种方法是使用自定义身份验证令牌类(不使用默认的UsernamePasswordAuthenticationToken)。使用自定义身份令牌类,还需要自定义身份验证的过程。需要编写AuthenticationProvider或扩展DaoAuthenticationProvider来处理令牌类。

还有一种简单的方法是将多余的字段与用户名连接起来(例如,使用“:”作为分隔符),并将它们传递到UsernamePasswordAuthenticationTokenusername属性中。这种方式需要实现自己的UserDetailsService,在其中把username进行拆分。

解决方案抉择

我们的需求是Json格式。所以拼接方法 pass 掉。

又因为,我们只需要添加验证码字段,比较简单,所以我们也暂时不自定义身份验证自定义令牌类,后面我们需要支持多种登陆方式的时候,我们再使用这种方法。

根据文档的说法,提交的登录信息由UsernamePasswordAuthenticationFilter的实例处理。所以我们可以自定义一个 filter 继承这个类,然后定义自己的处理方式。

所以最后的解决方案定为:自定义 MyAuthenticationProcessingFilter extend UsernamePasswordAuthenticationFilter,在这其中拿到 request 请求,把请求体( json )反序列化成实体类,然后校验验证码,通过后再封装成UsernamePasswordAuthenticationToken,向后传递。

实现

此章代码实现均在前章代码的基础上进行。

  1. 定义接收参数的实体

    @Data
    public class LoginData {
        /** 用户名 */
        private String username;
        /** 密码 */
        private String password;
        /** 普通登陆验证码 */
        private String commonLoginVerifyCode;
    }
    
  2. 自定义认证入口过滤器

    这个我们可以参考UsernamePasswordAuthenticationToken的实现来编写。

    UsernamePasswordAuthenticationToken#attemptAuthentication

    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 username = obtainUsername(request);
            String password = obtainPassword(request);
            if (username == null) {
                username = "";
            }
            if (password == null) {
                password = "";
            }
            username = username.trim();
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                    username, password);
            // Allow subclasses to set the "details" property
            setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    

    MyAuthenticationProcessingFilter#attemptAuthentication

    @Override
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException{
     
            // 校验请求方法、请求体格式
            if (!request.getMethod().equals(HttpMethod.POST.name())) {
                throw new AuthenticationServiceException(
                        "Authentication method not supported: " + request.getMethod());
            }
            if (!request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)) {
                throw new AuthenticationServiceException(
                        "Authentication contentType not supported: " + request.getContentType());
            }
     
            // 序列化请求体
            LoginData loginData = new LoginData();
            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,false);
            try {
                loginData = objectMapper.readValue(request.getInputStream(), LoginData.class);
            } catch (IOException e) {
                throw new AuthenticationServiceException(
                        "loginDataJson to LoginData failed");
            }
     
            // 模拟校验验证码
            String loginVerifyCode = loginData.getCommonLoginVerifyCode();
            if (!"111111".equals(loginVerifyCode)) {
                throw new AuthenticationServiceException("commonLoginVerifyCode is wrong");
            }
     
            // 传递令牌类 UsernamePasswordAuthenticationToken
            String username = loginData.getUsername();
            String password = loginData.getPassword();
            username = username == null ? "":username;
            password = password == null ? "":password;
     
            username = username.trim();
            UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password);
            setDetails(request,usernamePasswordAuthenticationToken);
            return this.getAuthenticationManager().authenticate(usernamePasswordAuthenticationToken);
        }
     
    
  3. Security 配置类

    @Configuration
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
     
        @Autowired
        MyUserDetailServiceImpl userDetailService;
     
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.userDetailsService(userDetailService);
        }
     
        // 配置自定 Filter 的 Bean
        @Bean
        MyAuthenticationProcessingFilter myAuthenticationProcessingFilter() throws Exception {
     
            MyAuthenticationProcessingFilter filter = new MyAuthenticationProcessingFilter();
     
            // 认证成功的处理办法
            filter.setAuthenticationSuccessHandler((request, response, authentication) -> {
                response.setContentType("application/json;charset=utf-8");
                PrintWriter out = response.getWriter();
                MyUserDetails userDetails = (MyUserDetails) authentication.getPrincipal();
                userDetails.setPassword(null);
                HashMap<String, Object> result = new HashMap<>();
                result.put("code","000000");
                result.put("msg","登陆成功");
                result.put("data",userDetails);
                String s = new ObjectMapper().writeValueAsString(result);
                out.write(s);
                out.flush();
                out.close();
         });
     
            // 认证错误的处理办法
            filter.setAuthenticationFailureHandler((request, response, exception) -> {
                response.setContentType("application/json;charset=utf-8");
                PrintWriter out = response.getWriter();
                HashMap<String, Object> result = new HashMap<>();
                result.put("code","111111");
                if (exception instanceof LockedException) {
                    result.put("msg","账户已锁定!");
                } else if (exception instanceof DisabledException) {
                    result.put("msg","账户已禁用!");
                } else if (exception instanceof BadCredentialsException) {
                    result.put("msg","用户名或者密码输入错误,请重新输入!");
                } else if (exception instanceof AuthenticationServiceException) {
                    result.put("msg",exception.getMessage());
                }
                out.write(new ObjectMapper().writeValueAsString(result));
             out.flush();
                out.close();
            });
     
            filter.setAuthenticationManager(authenticationManagerBean());
         filter.setFilterProcessesUrl("/toLogin");
            return filter;
        }
     
        // 这个方法可以算是 Security 配置类的配置中心
        @Override
        protected void configure(HttpSecurity http) throws Exception {
           // 拦截所有请求
            http.authorizeRequests()
                    .anyRequest()
                    .authenticated()
                    .and()
                    .csrf()
                    .disable();
            // 替换 filter
            http.addFilterAt(myAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class);
        }
    

登陆

  1. 尝试使用表单登陆

    {
      "msg":"Authentication contentType not supported: multipart/form-data; boundary=--------------------------730516048605803229023191",
      "code":"111111"
    }
    
  2. 使用 json 登陆,携带错误验证码

    {
      "msg":"commonLoginVerifyCode is wrong",
      "code":"111111"
    }
    
  3. 使用 json 登陆,密码错误

    {
        "msg": "用户名或者密码输入错误,请重新输入!",
        "code": "111111"
    }
    
  4. 正确

    这里还返回了一些没用的信息,实际项目中可以按需去掉。

    {
        "msg": "登陆成功",
        "code": "000000",
        "data": {
            "id": 1,
            "phone": null,
            "username": "张三",
            "password": null,
            "accountStatus": 1,
            "roleList": [
                {
                    "id": 1,
                    "name": "admin",
                    "desc": null
                },
                {
                    "id": 2,
                    "name": "user",
                    "desc": null
                }
            ],
            "enabled": true,
            "authorities": [
                {
                    "authority": "admin"
                },
                {
                    "authority": "user"
                }
            ],
            "accountNonLocked": true,
            "accountNonExpired": true,
            "credentialsNonExpired": true
        }
    }