写在前面

在前一篇《详解登录流程》一文中,笔者提到当开发者需要在SpringSecurity中自定义一个登录验证码或者将登录参数修改为JSON时,都需要自定义自己的Filter类,并继承这个AbstractAuthenticationProcessingFilter类,那么接下来的两篇就分别介绍如何自定义登录验证码和将登录参数修改为JSON格式。

本文是在之前的security-jpa项目上进行修改的。

生成验证码

既然想在SpringSecurity中使用验证码,那么首先是生成验证码,这里采用Java来生成验证码。

新建一个utils包,并在里面新建一个生成验证码的工具类VerifyCode,里面的代码如下:

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
public class VerifyCode {
//定义生成验证码图片的宽度
private int width = 100;
//定义生成验证码图片的高度
private int height = 50;
//定义生成验证码的字体
private String[] fontName = { "宋体", "楷体", "隶书", "微软雅黑"};
//定义生成验证码图片的背景颜色,此处为白色
private Color bgColor = new Color(255,255,255);
//定义生成验证码中字符的取值范围
private String codes = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
//记录生成验证码中的字符串
private String text;
//定义一个随机对象
private Random random = new Random();
//定义验证码中字符的个数,此处为4个
private int charNumber = 4;
//定义验证码中干扰线的条数,一般会比字符多一些,此处为6条
private int lineNumber = 6;


/**
* 获取一个随机字符颜色
* */
private Color randomColor(){
//注意颜色取值在[0,255]之间,考虑效果这里设置为[0.200]
int red = random.nextInt(200);
int green = random.nextInt(200);
int blue = random.nextInt(200);
return new Color(red,green,blue);
}

/**
* 获取一个随机字符字体
* */
private Font randomFont(){
//Font(name,style,size),其中name为字体名称,style为风格,size为字号
//随机获取前面设置的字体名称
String name = fontName[random.nextInt(fontName.length)];
int style = random.nextInt(4);
int size = random.nextInt(5)+25;
return new Font(name,style,size);
}

/**
* 获取一个随机字符
* */
private char randomChar(){
return codes.charAt(random.nextInt(codes.length()));
}

/**
* 获取生成验证码中的字符串
* */
public String getText(){
return text;
}

/**
* 绘制干扰线
* */
private void drawLine(BufferedImage bufferedImage){
Graphics2D g2 = (Graphics2D)bufferedImage.getGraphics();
for(int i=0;i<lineNumber;i++){
//两点确定一条直线,因此x1,y1(x2,y2)确定起点(终点)的横纵坐标,且取值均在验证码图片范围内
int x1 = random.nextInt(width);
int y1 = random.nextInt(height);
int x2 = random.nextInt(width);
int y2 = random.nextInt(height);
//设置线条颜色
g2.setColor(randomColor());
//设置线条粗细
g2.setStroke(new BasicStroke(1.5f));
g2.drawLine(x1,y1,x2,y2);
}
}

/**
* 创建一个空白的BufferedImage对象
* */
private BufferedImage createBufferedImage(){
BufferedImage bufferedImage = new BufferedImage(width,height,BufferedImage.TYPE_INT_RGB);
Graphics2D g2 = (Graphics2D)bufferedImage.getGraphics();
//设置验证码图片的背景颜色
g2.setColor(bgColor);
g2.fillRect(0,0,width,height);
return bufferedImage;
}

/**
* 获取一个BufferedImage对象
* */
public BufferedImage getBufferedImage(){
BufferedImage bufferedImage = createBufferedImage();
Graphics2D g2 = (Graphics2D)bufferedImage.getGraphics();
StringBuffer stringBuffer = new StringBuffer();
for(int i =0;i<charNumber;i++){
//随机获得一个字符
String s = randomChar()+"";
stringBuffer.append(s);
//设置当前获得字符的颜色
g2.setColor(randomColor());
//设置当前获得字符的字体
g2.setFont(randomFont());
float x = i * width*1.0f/4;
//依据样式绘制当前获得的字符
g2.drawString(s,x,height - 15);
}
//给BufferedImage对象设置字符串
this.text = stringBuffer.toString();
//绘制干扰线
drawLine(bufferedImage);
return bufferedImage;
}

/**
* 输出验证码图片
* */
public static void out(BufferedImage bufferedImage, OutputStream outputStream) throws IOException {
ImageIO.write(bufferedImage,"JPEG",outputStream);
}
}

接着就是提供一个接口,通过流将验证码写到前端页面。新建一个VerifyCodeController类,里面的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
public class VerifyCodeController {
@GetMapping("/vercode")
public void code(HttpServletRequest request, HttpServletResponse response) throws IOException {
VerifyCode verifyCode = new VerifyCode();
//获取验证码
BufferedImage image = verifyCode.getBufferedImage();
//获取验证码文字
String text = verifyCode.getText();
HttpSession session = request.getSession();
session.setAttribute("verify_code",text);
VerifyCode.out(image,response.getOutputStream());
}
}

可以看到这里我们将生成的验证码中的字符串保存到session中,这样后续就可以通过流将图片写到前端页面中。

如果开发者没有使用SpringSecurity框架,那么static目录下的文件是可以直接访问的,无需通过视图解析器进行解析。但是这里使用了SpringSecurity框架,因此需要在configure(WebSecurity web)方法中进行过滤。

第一步,在static目录下新建verifyCode.html页面,其中的代码如下:

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<img src="/vercode" alt="验证码">
</body>
</html>

可以看到这个页面非常简单,里面有一个src属性,它请求的就是之前验证码提供的接口。

第二步,修改MySecurityConfig#configure(WebSecurity web)方法为如下所示:

1
2
3
4
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/js/**", "/css/**","/vercode","/VerifyCode.html");
}

之后重启项目,在浏览器中访问http://localhost:8080/vercode,可以看到页面如下所示:

再来访问http://localhost:8080/VerifyCode.html,可以看到页面如下所示:

这样用户无需登录就能请求到验证码,之后开发者就可以将其内嵌到登录表单中。

自定义验证码处理器

自定义验证码处理器非常重要,当用户添加了验证码,那么在进行用户认证的时候不仅要验证用户名和密码,还需要验证输入的验证码,通过自定义验证码处理器我们就能实现上述功能。请注意,验证码的验证通常都是在用户名和密码验证之前进行的。

新建一个filter包,并在里面新建一个VerifyCodeFilter类,注意这个类需要继承GenericFilterBean类:

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
@Component
public class VerifyCodeFilter extends GenericFilterBean {
//设置默认的过滤处理路径,其实就是默认的登录处理路径
private String defaultFilterProcessUrl = "/goLogin";

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
if("POST".equalsIgnoreCase(request.getMethod()) && defaultFilterProcessUrl.equals(request.getServletPath())){
//获取用户通过表单输入的验证码字符串
String formCaptcha =request.getParameter("code");
//获取生成的验证码字符串(从session中获取)
String genCaptcha = (String) request.getSession().getAttribute("verify_code");

response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();

if(StringUtils.isEmpty(formCaptcha) || StringUtils.isEmpty(genCaptcha)){
//判断用户输入的验证码是否为空
out.write(new ObjectMapper().writeValueAsString("验证码不能为空!"));
out.flush();
out.close();
return;
}
if(!genCaptcha.toLowerCase().equals(formCaptcha.toLowerCase())){
//用户输入的验证码和生成的验证码是否一致
out.write(new ObjectMapper().writeValueAsString("验证码错误!"));
out.flush();
out.close();
return;
}
}
filterChain.doFilter(request,response);
}
}

可以看到这里自定义的VerifyCodeFilter过滤器继承了GenericFilterBean类,现在问题来了,为什么不继承一开始提到的AbstractAuthenticationProcessingFilter类了,原因在于
AbstractAuthenticationProcessingFilter类继承于GenericFilterBean类,而GenericFilterBean类实现了Filter接口,这个Fileter接口中有一个doFilter()方法,我们就是需要这个doFilter()方法,同时我们又不想实现过多的抽象方法,因此继承这个GenericFilterBean类。当然了,开发者还可以实现Filter接口,这样也是只需实现doFilter()方法,两种方式任选一种即可。

说白了就是开发者可以使用如下方式中的任意一种:

1
2
3
4
public class VerifyCodeFilter implements Filter(){
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain){}
}

或者

1
2
3
4
public class VerifyCodeFilter extends GenericFilterBean(){
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain){}
}

回到doFilter方法中,可以看到当请求方式为POST,且请求地址为/goLogin,其实就是登录地址,因为验证码都是在登录的时候需要输入,之后从用户输入的表单中获取code字段的值,接着获取session中保存的验证码信息,如果获取的用户验证码或者生成的新验证码为空则抛出“验证码不能为空”的提示信息,如果获取的用户验证码与从session中获取的验证码不一致,则抛出“验证码错误”这一提示信息。如果验证码匹配成功,则继续执行filterChain.doFilter(request,response)方法使得请求可以继续往下执行。

配置过滤器执行顺序

前面提到,验证码的验证通常都是在用户名和密码验证之前进行的,因此需要在MySecurityConfig类中将验证码的验证放在用户名和密码验证之前进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private VerifyCodeFilter verifyCodeFilter;
....
....
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(verifyCodeFilter, UsernamePasswordAuthenticationFilter.class);
http.authorizeRequests()
....
}

代码非常简单,用户只需添加如下核心代码:

1
http.addFilterBefore(verifyCodeFilter, UsernamePasswordAuthenticationFilter.class);

这样就完成了验证码过滤器的配置。

测试项目

启动项目,接下来使用Postman以POST方式访问http://localhost:8080/goLogin链接,如下所示:

请注意这里的“验证码不为为空”是由于用户没有提前访问http://localhost:8080/vercode链接,无法生成二维码而导致的。由于这里使用的是前后端分离模式,因此对于二维码必须提前访问,之后拿到生成的二维码配合用户名和密码进而完成登录功能。如果开发者之前看过renren-fast开源项目的源码,尽管它使用的是Shiro框架,但是遵循的是先拿到二维码,之后才配合用户名和密码完成登录功能,如下所示:

其实细心的你可能也发现了一个问题,就是这里的/goLogin接口其实采用的还是Key/Value形式的键值对来传输数据(Body中的x-www-form-urlencoded和最明显的Key、Value),而真正意义上的JSON格式应当是如下所示:

但是这里并没有,那么如何实现真正意义上的前后端分离登录呢?这些笔者会在下一篇《填坑,前后端分离JSON格式登录实现》一文中介绍。同时可以发现本篇采用自定义过滤器,并将该过滤器放入SpringSecurity的过滤器链中,这种方式其实是有问题的,我们仅仅需要登录的请求经过该过滤器,其他请求是无需经过的,因此如果你对性能有较为严苛的要求,那么就有必要对上述逻辑进行修改,那么如何修改呢,笔者会在后续文章中进行介绍。