关于 JWT,松哥之前其实写过相关的教程。最近有小伙伴在微信上发消息,问松哥能不能分析一下若依项目中 JWT 登录流程,因为这个项目现在有不少人将之作为脚手架来开发商业项目。我周末抽空看了下,感觉还蛮简单的,于是整一篇文章和大家分享一下这里的 JWT 登录是咋玩的。
本文我将从如下几个方面来和大家分析:
好啦,不废话了,咱们开整吧!
若依这个项目有单体版的也有微服务版的,我这里以单体版的为例来和小伙伴们分享,微服务版的以后有空了也可以整一篇文章和大家捋一捋。
单体版的项目大家可以从 Gitee 上 clone,clone 地址:
首先你得先把若依这个项目跑起来,这是一个最最基本的要求了,我觉得没啥好说的。而且它这个运行比较容易,数据库弄好,在项目的配置文件中配一下数据库用户名密码以及 redis 的相关信息即可。
这个相信大家都能自己搞得定,我就不再多说了。
项目启动成功之后,启动页面有一个验证码,浏览器按 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;
}
验证码的大致逻辑是这样:
这就是验证码的生成过程。
登录相关的配置在 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 的用法还不熟悉,可以在公众号后台回复 ss
获取 Spring Security 教程链接。
这里的登录接口是在 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);
}
来看下令牌的创建过程:
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,就是这么个过程。
当用户登录成功后,以后每次发送请求的时候,都要携带上 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());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request, response);
}
}
ss
有相关的教程(在 Spring Security 中,我就想从子线程获取用户登录信息,怎么办?)。如果当前请求中没有 token,那么拿到的 loginUser 就为 null,这个过滤器继续向下走,在 Spring Security 最终的过滤器链中,就会自动检测到用户没登录,进而抛出异常。
好了,大概上就是这么个过程,这个过滤器会在第二小节的配置中,被加入到 Spring Security 的过滤器链中。
好啦,今天就和大家简单梳理了一下若依这个项目的登录,一些 Spring Security 的使用细节我并没有过多的去展开,如果大家对 Spring Security 的用法不熟练的话,可以看看松哥之前的 Spring Security 教程,公众号后台回复 ss
有链接。