写在前面
如果你之前阅读过笔者写过的《前后端分离回调和注销登录》和《添加登录验证码》两篇文章,会发现一个比较尴尬的事情,当时我们说好是采用前后端分离模式,前后端之间以JOSN格式的数据进行传输,但是在用户登录的时候,我们其实并没有采用JSON格式,依旧使用了Key/Value键值对形式。除此之外,其余所有的POST请求都采用了JSON格式,这是当时笔者偷了一个懒所导致的。原因在于SpringSecurity中默认的登录数据格式就是Key/Value键值对形式,因此我就直接使用了,没做修改,但是在实际工作中有必要进行全部统一,因此本篇就来将其进行改造,使之也采用JSON格式来传输数据。
现有方式
通过《详解登录流程》一文,我们知道用户登录信息是在UsernamePasswordAuthenticationFilter
类中处理的,里面有三个重要的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| 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); username = username != null ? username : ""; username = username.trim(); String password = this.obtainPassword(request); password = password != null ? password : ""; UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); this.setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } }
@Nullable protected String obtainPassword(HttpServletRequest request) { return request.getParameter(this.passwordParameter); }
@Nullable protected String obtainUsername(HttpServletRequest request) { return request.getParameter(this.usernameParameter); }
|
这个attemptAuthentication方法是认证首先会执行的方法,其中调用了obtainUsername和obtainPassword方法来获取用户从表单中输入的用户名和密码,为什么是从表单中获取呢?因为它采用了request.getParameter(parameterName)
方式,所以说SpringSecurity中默认的登录数据格式就是Key/Value键值对形式。
因此如果开发者想将此处的Key/Value键值对形式修改为JSON格式,那么就需要自定义一个过滤器来替代此处的UsernamePasswordAuthenticationFilter
,并在该过滤器中提供获取参数的新方法。
请注意,由于本篇依旧是在前一篇《添加验证码》一文的基础上进行的,因此这里自定义了过滤器,那么也需要重新定义获取验证码的方法。
自定义过滤器
自定义过滤器有两种方法,第一种是继承UsernamePasswordAuthenticationFilter
类,第二种则是继承UsernamePasswordAuthenticationFilter
的父类,这里选择第一种,因为那样可以做到和之前保持一致,且部分方法的实现逻辑可以参考它。
在filter包内新建一个EnvyLoginFilter
类,并让这个类继承之前的UsernamePasswordAuthenticationFilter
类,里面的代码如下所示:
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
| /** * 前后端分离模式下使用JSON格式传输数据 * */ @Component public class EnvyLoginFilter extends UsernamePasswordAuthenticationFilter { @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (!request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } else { //获取验证码 String verify_code = (String) request.getSession().getAttribute("verify_code"); //判断请求类型 if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE) || request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)) { //说明此时使用的是JSON方式 Map<String, String> loginData = new HashMap<>(); try { loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class); } catch (IOException e) { e.printStackTrace(); } finally { //规定JSON格式中验证码key为code String code = loginData.get("code"); checkCode(response, code, verify_code); } String username = loginData.get(getUsernameParameter()); String password = loginData.get(getPasswordParameter());
username = username != null ? username.trim() : ""; password = password != null ? password : "";
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); this.setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } else { //说明此时使用的是Key/Value键值对方式 checkCode(response, request.getParameter("code"), verify_code); return super.attemptAuthentication(request, response); } } }
public void checkCode(HttpServletResponse response, String code,String verify_code) { if(code==null || "".equals(code) || verify_code ==null || !verify_code.toLowerCase().equals(code.toLowerCase())){ response.setContentType("application/json;charset=utf-8"); PrintWriter out = null; try { out = (PrintWriter) response.getWriter(); } catch (IOException e) { e.printStackTrace(); } out.write("验证码不正确!"); out.flush(); out.close(); } } }
|
可以看到这个类中重写了attemptAuthentication
方法,并添加了一个checkCode方法。首先查看一下attemptAuthentication
方法,可以看到它基本上是参照了父类的UsernamePasswordAuthenticationFilter#attemptAuthentication
方法,不同的是对于验证码、用户名和密码的获取方式进行了修改。该方法的执行逻辑如下所示:
(1)首先判断登录请求是否为POST,由于此处是POST方法处理逻辑,如果不是就直接抛出异常,不再进行后续处理;
(2)从Session中获取访问/vercode
接口时生成的验证码;
(3)通过请求的ContentType来判断是当前请求是通过JSON来传递参数,还是以其他方式,也就说当前定义的EnvyLoginFilter
类既支持使用JSON格式,也支持使用Key/Value方式来传递参数。
(4)如果(3)得到以JSON格式来传递参数,那么就读取Request中的I/O流并将JOSN数据映射到一个Map中。
(5)从Map中取出验证码code,并判断验证码是否为空,是否和(2)中获取到的验证码一致,如果一致则继续(6),否则就将错误信息以JSON格式写入响应中。
(6)验证码验证无误后,接下来从Map中获取username和password。请注意这里必须是username和password,原因在于如下代码:
1 2
| String username = loginData.get(getUsernameParameter()); String password = loginData.get(getPasswordParameter());
|
而这个getUsernameParameter()
和getPasswordParameter()
方法存在于UsernamePasswordAuthenticationFilter
类中,这两个方法默认获取的信息如下:
所以这里就必须使用username和password,言外之意,等会进行测试的时候,用户名和密码以JSON格式进行传输时,其Key分别为username和password。
(7)对于用户名和密码的验证这里构造了一个UsernamePasswordAuthenticationToken
对象,然后参考UsernamePasswordAuthenticationFilter
类中attemptAuthentication
方法定义的逻辑来进行验证。
(8)当(3)中采用Key/Value方式来传递参数,那么此时使用request.getParameter("code")
方法从表单中获取输入的验证码字符串,之后对验证码进行验证,验证通过后就直接调用父类(即UsernamePasswordAuthenticationFilter
)的attemptAuthentication
方法来进行登录验证,而这个就是SpringSecurity对于默认登录方式的处理逻辑。
提供EnvyLoginFilter实例
在前面我们自定义了一个用于“前后端分离模式下使用JSON格式传输数据”的过滤器,因此接下来要做的就是使用这个自定义的过滤器替代默认的UsernamePasswordAuthenticationFilter
。
在MySecurityConfig类中创建一个提供EnvyLoginFilter
实例的方法envyLoginFilter
,里面的代码如下所示:
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
| @Bean EnvyLoginFilter envyLoginFilter() throws Exception { EnvyLoginFilter envyLoginFilter = new EnvyLoginFilter(); //设置登录成功时逻辑 envyLoginFilter.setAuthenticationSuccessHandler((httpServletRequest,httpServletResponse,authentication)->{ httpServletResponse.setContentType("application/json;charset=utf-8"); PrintWriter out = httpServletResponse.getWriter(); Map<String,Object> map= new HashMap(); User user = (User) authentication.getPrincipal(); user.setPassword(null); map.put("status",200); map.put("user",user); out.write(new ObjectMapper().writeValueAsString(map)); out.flush(); out.close(); }); //设置登录失败时逻辑 envyLoginFilter.setAuthenticationFailureHandler((httpServletRequest,httpServletResponse,exception)->{ httpServletResponse.setContentType("application/json;charset=utf-8"); PrintWriter out = httpServletResponse.getWriter(); Map<String,Object> map= new HashMap(); map.put("status",401); if (exception instanceof LockedException) { map.put("msg", "账户被锁定,请联系管理员!"); } else if (exception instanceof BadCredentialsException) { map.put("msg", "账户名或密码输入错误,请重新输入!"); } else if (exception instanceof DisabledException) { map.put("msg", "账户被禁用,请联系管理员!"); } else if (exception instanceof AccountExpiredException) { map.put("msg", "账户已过期,请联系管理员!"); } else if (exception instanceof CredentialsExpiredException) { map.put("msg", "密码已过期,请联系管理员!"); } else { map.put("msg", "登录失败!"); } out.write(new ObjectMapper().writeValueAsString(map)); out.flush(); out.close(); }); envyLoginFilter.setAuthenticationManager(authenticationManagerBean()); envyLoginFilter.setFilterProcessesUrl("/goLogin"); return envyLoginFilter; }
|
解释一下上述代码的含义:
(1)首先实例化一个EnvyLoginFilter
对象,之后设置它登录成功时的逻辑。这里使用了lambda表达式来返回一个AuthenticationSuccessHandler
对象,该对象设置了请求内容类型为JSON,并获取了当前登录用户信息,然后将其密码设置为空,之后构建一个返回对象map,将当前登录信息进行返回。
(2)之后设置了登录失败时的逻辑,同样也使用了lambda表达式来返回一个AuthenticationFailureHandler
对象,该对象设置了请求内容类型为JSON,并获取了登录失败的原因,之后构建一个返回对象map,将当前登录失败异常进行返回。
(3)无论登录成功与否,都需要设置一个AuthenticationManager
对象,该对象可以根据WebSecurityConfigurerAdapter#authenticationManagerBean()
方法来获取,这是SpringSecurity认证的原理。
(4)最后设置FilterProcessesUrl
也就是过滤请求处理路径,也就是当前过滤器在请求什么接口的时候触发执行。这里当然也是登录地址/goLogin
,如果不配置那么默认值为/login
。
请注意,由于此处EnvyLoginFilter实例仅仅配置了使用JSON格式登录成功和失败时的处理逻辑,因此当开发者使用FORM表单登录提交时的成功与否均无法处理,这一点很容易出错,需要开发者引起高度注意。
替换默认过滤器
接下来将MySecurityConfig
类中之前的configure(HttpSecurity http)
方法的代码给注释掉,新建一个同名方法,里面的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13
| @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/admin/**").hasRole("admin") .antMatchers("/user/**").hasRole("user") .anyRequest().authenticated() .and() .formLogin() .loginPage("/login.html") .loginProcessingUrl("/goLogin").permitAll() .and().csrf().disable(); http.addFilterAt(envyLoginFilter(),UsernamePasswordAuthenticationFilter.class); }
|
请注意,这里必须将之前用于验证码验证的http.addFilterBefore(verifyCodeFilter, UsernamePasswordAuthenticationFilter.class);
一行代码给删除,因为此时已经不再使用前面那种“以Key/Value键值对形式传递参数”的方式了。在最后面添加了如下一行代码:
1
| http.addFilterAt(envyLoginFilter(),UsernamePasswordAuthenticationFilter.class);
|
也就是说此处使用自定义的EnvyLoginFilter过滤器来替换默认的UsernamePasswordAuthenticationFilter
过滤器。
addFilterBefore(A,B)
表示A过滤器在B过滤器之前执行,addFilterAt(A,B)
表示使用A过滤器替换B过滤器。
测试项目
启动项目,接下来使用Postman先以GET方式访问http://localhost:8080/vercode
链接获取验证码,之后以POST方式访问http://localhost:8080/goLogin
链接,填入正确信息,此时页面如下所示:
可以看到此时用户确实登录成功了。那么本篇关于前后端分离模式下的JSON格式登录实现的学习就到这里,后续学习其他内容。