前后端分离 JWT 登录实践

关于 JWT,松哥之前其实写过相关的教程。最近有小伙伴在微信上发消息,问松哥能不能分析一下若依项目中 JWT 登录流程,因为这个项目现在有不少人将之作为脚手架来开发商业项目。我周末抽空看了下,感觉还蛮简单的,于是整一篇文章和大家分享一下这里的 JWT 登录是咋玩的。

本文我将从如下几个方面来和大家分析:

  1. 验证码分析
  2. 登录流程分析
  3. 认证校验流程分析

好啦,不废话了,咱们开整吧!

1. 准备工作

若依这个项目有单体版的也有微服务版的,我这里以单体版的为例来和小伙伴们分享,微服务版的以后有空了也可以整一篇文章和大家捋一捋。

单体版的项目大家可以从 Gitee 上 clone,clone 地址:

首先你得先把若依这个项目跑起来,这是一个最最基本的要求了,我觉得没啥好说的。而且它这个运行比较容易,数据库弄好,在项目的配置文件中配一下数据库用户名密码以及 redis 的相关信息即可。

这个相信大家都能自己搞得定,我就不再多说了。

2. 验证码

项目启动成功之后,启动页面有一个验证码,浏览器按 F12,我们很容易就能看到这个验证码来自 /captchaImage 接口,并且还能看到验证码图片是以 Base64 字符串的形式返回到前端的。

我们找到服务端的验证码接口,在 src/main/java/com/ruoyi/web/controller/common/CaptchaController.java 里:

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
@GetMapping("/captchaImage")
public AjaxResult getCode(HttpServletResponse response) throws IOException {
AjaxResult ajax = AjaxResult.success();
boolean captchaOnOff = configService.selectCaptchaOnOff();
ajax.put("captchaOnOff", captchaOnOff);
if (!captchaOnOff) {
return ajax;
}
// 保存验证码信息
String uuid = IdUtils.simpleUUID();
String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
String capStr = null, code = null;
BufferedImage image = null;
// 生成验证码
String captchaType = RuoYiConfig.getCaptchaType();
if ("math".equals(captchaType)) {
String capText = captchaProducerMath.createText();
capStr = capText.substring(0, capText.lastIndexOf("@"));
code = capText.substring(capText.lastIndexOf("@") + 1);
image = captchaProducerMath.createImage(capStr);
} else if ("char".equals(captchaType)) {
capStr = code = captchaProducer.createText();
image = captchaProducer.createImage(capStr);
}
redisCache.setCacheObject(verifyKey, code, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES);
// 转换流信息写出
FastByteArrayOutputStream os = new FastByteArrayOutputStream();
try {
ImageIO.write(image, "jpg", os);
} catch (IOException e) {
return AjaxResult.error(e.getMessage());
}
ajax.put("uuid", uuid);
ajax.put("img", Base64.encode(os.toByteArray()));
return ajax;
}

验证码的大致逻辑是这样:

  1. 首先调用 configService.selectCaptchaOnOff() 方法去数据库 sys_config 表中查询验证码是开启的还是关闭的,如果验证码是关闭的,那么这里就不需要返回验证码的图片,前端将来也不会显示出来验证码。这种系统配置,在项目启动的时候会自动存到 Redis 中,所以当调用 selectCaptchaOnOff 方法时,并不是每一次都去数据库中查询。
  2. 接下来就准备生成验证码了,这里使用 GitHub 上的开源项目 kaptcha (https://github.com/penggle/kaptcha)来生成验证码,验证码有两种模式,math 和 char,math 验证码图片上显示的是一个四则运算,给出计算结果;char 验证码图片上显示的就是大家常见的字符串。具体使用哪一个,是通过 RuoYiConfig.getCaptchaType() 配置来设置的,该配置的值是从 application.yaml 中读取的,即修改 application.yaml 中的 ruoyi.captchaType 属性值,可以修改验证码的形式。
  3. 接下来,将生成的验证码文本存入 redis 中,同时设置一个过期时间,默认的过期时间是两分钟,意思是,一个验证码生成之后,如果用户两分钟之内还没登录,那么验证码就过期了。这里大家注意 redis 中的 key,这个 key 是一个固定的字符串加上 uuid 生成的,这样就能确保每位用户的验证码不会冲突。
  4. 最后就是把生成的验证码图片搞成一个 Base64 字符串返回到前端。

这就是验证码的生成过程。

3. 登录配置

登录相关的配置在 src/main/java/com/ruoyi/framework/config/SecurityConfig.java 类中,我们先来看看:

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
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 自定义用户认证逻辑
*/
@Autowired
private UserDetailsService userDetailsService;

/**
* 认证失败处理类
*/
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;

/**
* 退出处理类
*/
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;

/**
* token认证过滤器
*/
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;

/**
* 跨域过滤器
*/
@Autowired
private CorsFilter corsFilter;

/**
* 解决 无法直接注入 AuthenticationManager
*
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/register", "/captchaImage").anonymous()
.antMatchers(
HttpMethod.GET,
"/",
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js",
"/profile/**"
).permitAll()
.antMatchers("/swagger-ui.html").anonymous()
.antMatchers("/swagger-resources/**").anonymous()
.antMatchers("/webjars/**").anonymous()
.antMatchers("/*/api-docs").anonymous()
.antMatchers("/druid/**").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 添加CORS filter
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}

/**
* 强散列哈希加密实现
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}

/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
}

都是 Spring Security 的常规配置,没啥好说的,松哥大概上看了一眼,这里涉及到的所有知识点我都在 Spring Security 系列教程里和大家聊过,所以这里也不再赘述了。

我这里给出几篇旧文的链接,有助于大家理解这里的配置:

  1. 松哥手把手带你入门 Spring Security,别再问密码怎么解密了
  2. 手把手教你定制 Spring Security 中的表单登录
  3. Spring Security 做前后端分离,咱就别做页面跳转了!统统 JSON 交互
  4. Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!
  5. Spring Security 多种加密方案共存,老破旧系统整合利器!

如果大家对于 Spring Security 的用法还不熟悉,可以在公众号后台回复 ss 获取 Spring Security 教程链接。

4. 登录接口

这里的登录接口是在 com.ruoyi.web.controller.system.SysLoginController#login 方法中,如下:

1
2
3
4
5
6
7
8
9
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody) {
AjaxResult ajax = AjaxResult.success();
// 生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid());
ajax.put(Constants.TOKEN, token);
return ajax;
}

那么我们可以看到,登录的核心逻辑在 loginService#login 方法中,一起来看下:

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
public String login(String username, String password, String code, String uuid) {
boolean captchaOnOff = configService.selectCaptchaOnOff();
// 验证码开关
if (captchaOnOff) {
validateCaptcha(username, code, uuid);
}
// 用户验证
Authentication authentication = null;
try {
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
} catch (Exception e) {
if (e instanceof BadCredentialsException) {
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
} else {
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new ServiceException(e.getMessage());
}
}
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
recordLoginInfo(loginUser.getUserId());
// 生成token
return tokenService.createToken(loginUser);
}
  • 首先去校验一下验证码,这个逻辑没啥好说的,从 Redis 中把数据拿出来做个比较就行了。
  • 调用 authenticationManager#authenticate 方法手动完成用户校验,如果登录成功就正常执行,如果登录失败,就会抛出异常。
  • 接下来有一个异步任务,将用户的登录日志写入到数据库中。
  • 然后还更新了一下用户表(更细了登录 IP、时间等信息)。
  • 最后创建一个 JWT 令牌。

来看下令牌的创建过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public String createToken(LoginUser loginUser) {
String token = IdUtils.fastUUID();
loginUser.setToken(token);
setUserAgent(loginUser);
refreshToken(loginUser);
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
return createToken(claims);
}
private String createToken(Map<String, Object> claims) {
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
public void refreshToken(LoginUser loginUser) {
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 根据uuid将loginUser缓存
String userKey = getTokenKey(loginUser.getToken());
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}

其实这两个方法也没啥好说的,松哥之前的文章也和大家聊过 JWT(公号后台回复 666 有相关内容),这里 JWT 的生成过程跟松哥之前所说的基本上也是一模一样。大家从 JWT 生成的代码中可以看到,JWT 是通过 claims 变量生成的,该变量里边只有一个键值对,那就是一个 uuid 字符串。在生成 token 的过程中,有一个 refreshToken,这个方法中会以当前的 uuid 为 key,将登录的用户信息存入 redis 中,并为该信息设置一个过期时间,默认的过期时间是 30 分钟。

最终,这里的 token 会被写回到前端,在前端登录成功之后,用户就可以拿到这个令牌。

以后前端每次请求的时候,都自己带上这个 token,当然这是前端的事,我们不用管。

松哥在之前的文章中和大家聊 JWT 的时候,说这是一种典型的无状态登录方案,但是无状态登录无法解决用户的注销等问题,所以我们在若依的项目中看到,虽然他用到了 JWT,但是本质上其实还是一种有状态登录,只不过登录的信息没有存在 session 中,而是存在 redis 中,以前那个由浏览器自动传递的 jsessionid 现在改为了用户手动传递 token,就是这么个过程。

5. 认证

当用户登录成功后,以后每次发送请求的时候,都要携带上 token 令牌,当然这是前端的事情,我们这里暂且不讨论。

我们来看看后续来的请求是如何验证有没有登录的。

相关的代码在 src/main/java/com/ruoyi/framework/security/filter/JwtAuthenticationTokenFilter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private TokenService tokenService;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
LoginUser loginUser = tokenService.getLoginUser(request);
if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) {
tokenService.verifyToken(loginUser);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request, response);
}
}
  1. tokenService.getLoginUser 这个方法从登录请求的请求头中拿出来 token,再根据这个 token,去 redis 中获取到 LoginUser。
  2. 如果从 redis 中拿到了 LoginUser,并且当前的 SecurityContextHolder 中没有用户认证信息,我们就去认证。大家知道,在 Spring Security 中,用户的认证信息其实是保存在 SecurityContextHolder 中的,如果大家不了解的话,在公众号后台回复 ss 有相关的教程(在 Spring Security 中,我就想从子线程获取用户登录信息,怎么办?)。
  3. 调用 tokenService.verifyToken 方法去验证 token,这里的验证其实就是验证一下这个 token 过期没,因为前面如果能拿到 loginUser 对象的话,就说明 token 是合法的,所以这里不需要进行合法性校验。同时在验证完 token 的有效期之后,还顺便刷新一下 redis 中 token 的有效期,以前用 session 的时候,服务端可以自动续签,现在只能手动了。
  4. 最后把认证成功的用户信息存到 SecurityContextHolder 中即可,后续如何需要校验,自然会读到该信息。

如果当前请求中没有 token,那么拿到的 loginUser 就为 null,这个过滤器继续向下走,在 Spring Security 最终的过滤器链中,就会自动检测到用户没登录,进而抛出异常。

好了,大概上就是这么个过程,这个过滤器会在第二小节的配置中,被加入到 Spring Security 的过滤器链中。

6. 小结

好啦,今天就和大家简单梳理了一下若依这个项目的登录,一些 Spring Security 的使用细节我并没有过多的去展开,如果大家对 Spring Security 的用法不熟练的话,可以看看松哥之前的 Spring Security 教程,公众号后台回复 ss 有链接。

喜欢这篇文章吗?扫码关注公众号【江南一点雨】【江南一点雨】专注于 SPRING BOOT+微服务以及前后端分离技术,每天推送原创技术干货,关注后回复 JAVA,领取松哥为你精心准备的 JAVA 干货!

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×