写在前面

如果你之前阅读过笔者写过的《前后端分离回调和注销登录》和《添加登录验证码》两篇文章,会发现一个比较尴尬的事情,当时我们说好是采用前后端分离模式,前后端之间以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格式登录实现的学习就到这里,后续学习其他内容。