关于 JWT,松哥之前其实写过相关的教程。最近有小伙伴在微信上发消息,问松哥能不能分析一下若依项目中 JWT 登录流程,因为这个项目现在有不少人将之作为脚手架来开发商业项目。我周末抽空看了下,感觉还蛮简单的,于是整一篇文章和大家分享一下这里的 JWT 登录是咋玩的。
本文我将从如下几个方面来和大家分析:
- 验证码分析
- 登录流程分析
- 认证校验流程分析
好啦,不废话了,咱们开整吧!
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; }
|
验证码的大致逻辑是这样:
- 首先调用 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
类中,我们先来看看:
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;
@Autowired private JwtAuthenticationTokenFilter authenticationTokenFilter;
@Autowired private CorsFilter corsFilter;
@Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity httpSecurity) throws Exception { httpSecurity .csrf().disable() .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() .authorizeRequests() .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); httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); 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
方法中,如下:
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 { 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()); 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); 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); } }
|
- tokenService.getLoginUser 这个方法从登录请求的请求头中拿出来 token,再根据这个 token,去 redis 中获取到 LoginUser。
- 如果从 redis 中拿到了 LoginUser,并且当前的 SecurityContextHolder 中没有用户认证信息,我们就去认证。大家知道,在 Spring Security 中,用户的认证信息其实是保存在 SecurityContextHolder 中的,如果大家不了解的话,在公众号后台回复
ss
有相关的教程(在 Spring Security 中,我就想从子线程获取用户登录信息,怎么办?)。
- 调用 tokenService.verifyToken 方法去验证 token,这里的验证其实就是验证一下这个 token 过期没,因为前面如果能拿到 loginUser 对象的话,就说明 token 是合法的,所以这里不需要进行合法性校验。同时在验证完 token 的有效期之后,还顺便刷新一下 redis 中 token 的有效期,以前用 session 的时候,服务端可以自动续签,现在只能手动了。
- 最后把认证成功的用户信息存到 SecurityContextHolder 中即可,后续如何需要校验,自然会读到该信息。
如果当前请求中没有 token,那么拿到的 loginUser 就为 null,这个过滤器继续向下走,在 Spring Security 最终的过滤器链中,就会自动检测到用户没登录,进而抛出异常。
好了,大概上就是这么个过程,这个过滤器会在第二小节的配置中,被加入到 Spring Security 的过滤器链中。
6. 小结
好啦,今天就和大家简单梳理了一下若依这个项目的登录,一些 Spring Security 的使用细节我并没有过多的去展开,如果大家对 Spring Security 的用法不熟练的话,可以看看松哥之前的 Spring Security 教程,公众号后台回复 ss
有链接。