前后端分离 JWT 登录实践
关于 JWT,松哥之前其实写过相关的教程。最近有小伙伴在微信上发消息,问松哥能不能分析一下若依项目中 JWT 登录流程,因为这个项目现在有不少人将之作为脚手架来开发商业项目。我周末抽空看了下,感觉还蛮简单的,于是整一篇文章和大家分享一下这里的 JWT 登录是咋玩的。
本文我将从如下几个方面来和大家分析:
- 验证码 分析
- 登录流程分析
- 认证校验流程分析
好啦,不废话了,咱们开整吧!
1. 准备工作
若依这个项目有单体版的也有微服务版的,我这里以单体版的为例来和小伙伴们分享,微服务版的以后有空了也可以整一篇文章和大家捋一捋。
单体版的项目大家可以从 Gitee 上 clone,clone 地址:
- https://gitee.com/y_project/RuoYi-Vue.git
首先你得先把若依这个项目跑起来,这是一个最最基本的要求了,我觉得没啥好说的。而且它这个运行比较容易, 数据库 弄好,在项目的配置文件中配一下数据库用户名密码以及 redis 的相关信息即可。
这个相信大家都能自己搞得定,我就不再多说了。
2. 验证码
项目启动成功之后,启动页面有一个验证码,浏览器按 F12,我们很容易就能看到这个验证码来自
/captchaImage
接口,并且还能看到验证码图片是以 Base64 字符串的形式返回到前端的。
我们找到服务端的验证码接口,在
src/main/java/com/ruoyi/web/controller/common/CaptchaController.java
里:
@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;
}
验证码的大致逻辑是这样:
- 首先调用 configService.selectCaptchaOnOff() 方法去数据库 sys_config 表中查询验证码是开启的还是关闭的,如果验证码是关闭的,那么这里就不需要返回验证码的图片,前端将来也不会显示出来验证码。这种系统配置,在项目启动的时候会自动存到 Redis 中,所以当调用 selectCaptchaOnOff 方法时,并不是每一次都去数据库中查询。
- 接下来就准备生成验证码了,这里使用 GitHub 上的开源项目 kaptcha (https://github.com/penggle/kaptcha)来生成验证码,验证码有两种模式,math 和 char,math 验证码图片上显示的是一个四则运算,给出计算结果;char 验证码图片上显示的就是大家常见的字符串。具体使用哪一个,是通过 RuoYiConfig.getCaptchaType() 配置来设置的,该配置的值是从 application.yaml 中读取的,即修改 application.yaml 中的 ruoyi.captchaType 属性值,可以修改验证码的形式。
- 接下来,将生成的验证码文本存入 redis 中,同时设置一个过期时间,默认的过期时间是两分钟,意思是,一个验证码生成之后,如果用户两分钟之内还没登录,那么验证码就过期了。这里大家注意 redis 中的 key,这个 key 是一个固定的字符串加上 uuid 生成的,这样就能确保每位用户的验证码不会冲突。
- 最后就是把生成的验证码图片搞成一个 Base64 字符串返回到前端。
这就是验证码的生成过程。
3. 登录配置
登录相关的配置在
src/main/java/com/ruoyi/framework/config/SecurityConfig.java
类中,我们先来看看:
@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 系列教程里和大家聊过,所以这里也不再赘述了。
我这里给出几篇旧文的链接,有助于大家理解这里的配置:
- 松哥手把手带你入门 Spring Security,别再问密码怎么解密了
- 手把手教你定制 Spring Security 中的表单登录
- Spring Security 做前后端分离,咱就别做页面跳转了!统统 JSON 交互
- Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!
- Spring Security 多种加密方案共存,老破旧系统整合利器!
如果大家对于 Spring Security 的用法还不熟悉,可以在公众号后台回复
ss
获取 Spring Security 教程链接。
4. 登录接口
这里的登录接口是在
com.ruoyi.web.controller.system.SysLoginController#login
方法中,如下:
@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 方法中,一起来看下:
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 令牌。
来看下令牌的创建过程:
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
:
@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());