前言:本文基于若依前后端分离版本(Spring Boot 3.3.0 + Vue 3 + Activiti 8.1.0)进行改造,相关教程可以在网上找到。在撰写此博客期间,笔者刚刚开始接触 Java Web,本系列下的文章内容包含大量“个人初期”视角,注意鉴别。
前端登录过程
前端使用store
管理用户user
状态,其中有一个状态变更逻辑的方法(说人话:登录操作):login
,通过用户名
、密码
、验证码
、uuid
四个参数像后端发送账户信息进行登录操作,其中的uuid
通过getCodeImg
获取,返回值为:验证码图像与uuid。
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 @GetMapping("/captchaImage") public AjaxResult getCode (HttpServletResponse response) throws IOException{ AjaxResult ajax = AjaxResult.success(); boolean captchaEnabled = configService.selectCaptchaEnabled(); ajax.put("captchaEnabled" , captchaEnabled); if (!captchaEnabled) { return ajax; } String uuid = IdUtils.simpleUUID(); String verifyKey = CacheConstants.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、通过IdUtils.simpleUUID()
生成唯一uid,并拼接常量字符,构造redis中的key:"captcha_codes:" + uuid
。
2、根据项目配置(数字类型的验证码),capStr
是算式部分,code
是答案。
3、将redisKey(常量字符串+uuid)与code(答案)保存到Redis数据库中,设定有效时间,时间粒度。均通过常量设定。
4、 返回前端。
后端处理
控制层:
1 2 3 4 5 6 7 8 9 10 @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; }
服务层:
核心流程
1、校验验证码,调用redisCache的函数,通过前文中的key拿出code,只要能拿出就删除,然后再验证是否正确。
2、前置校验用户名与用户密码,校验长度、判空等,不验证数据是否匹配。
3、封装用户信息,然后放到一个线程局部变量中。
4、AuthenticationManager
是 Spring Security
的核心接口之一,负责处理身份验证逻辑。会调用UserDetailsService
接口中的loadUserByUsername
方法执行用户信息的验证,对于验证成功的,会返回一个LogingUser
类(继承自UserDetails
类)
5、finally
中清除线程局部变量。
6、authentication
这个对象包含了用户的详细信息、权限等。通过getPrincipal()
获取到当前用户的详细信息,例如用户名、权限等。
7、生成用户专属token
(涉及到官方库函数的部分没有贴代码)
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 public String login (String username, String password, String code, String uuid) { validateCaptcha(username, code, uuid); loginPreCheck(username, password); Authentication authentication = null ; try { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken (username, password); AuthenticationContextHolder.setContext(authenticationToken); authentication = authenticationManager.authenticate(authenticationToken); } 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()); } } finally { AuthenticationContextHolder.clearContext(); } 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); }
生成token
1、生成一个uuid
,类似于:
token:600270c6-f3f0-472c-bec3-4bc49e43e6e3
2、设置用户的Agent
,主要是浏览器相关信息,包括IP地址、浏览器信息、操作系统信息等。
3、设置一些用户登录信息,最重要的地方在于将token
作为key,登录用户(Java对象)作为value保存到redis中,设置过期时间与时间粒度。
4、键值对保存用户信息到:claims = 常量字符串: token
然后执行token
生成,结果类似于:
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 ```JAVA 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); } public void setUserAgent(LoginUser loginUser) { UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent")); String ip = IpUtils.getIpAddr(); loginUser.setIpaddr(ip); loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip)); loginUser.setBrowser(userAgent.getBrowser().getName()); loginUser.setOs(userAgent.getOperatingSystem().getName()); } 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); } private String createToken(Map<String, Object> claims) { String token = Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret).compact(); return token; }
用户登录 --> 验证信息,生成token(未加密)–> 保存到redis(token(未加密):用户对象)
其他请求(携带加密后的token) --> 解码查找redis --> 重新获取用户对象。
具体应用
Java SecurityConfig中配置了许多可以放过不检查的请求,其他的请求需要验证header中携带的token,通过token解析登录用户的信息。
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 @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); } } public LoginUser getLoginUser (HttpServletRequest request) { String token = getToken(request); if (StringUtils.isNotEmpty(token)) { try { Claims claims = parseToken(token); String uuid = (String) claims.get(Constants.LOGIN_USER_KEY); String userKey = getTokenKey(uuid); LoginUser user = redisCache.getCacheObject(userKey); return user; } catch (Exception e) { log.error("获取用户信息异常'{}'" , e.getMessage()); } } return null ; }
所以这里我贴一段我个人的理解,以及POE给出的解释:
tomcat的线程生命周期是多久?比如一个登录操作,后端验证完成之后,将token+user对象保存到redis中,之后的请求都会去redis依靠token重新获取用户对象,所以在我眼里看来,tomcat或者说spring boot的后端线程基本上都是执行完一个操作之后就直接废弃了,每次的请求看redis中存储的信息
你对 Tomcat 和 Spring Boot 中线程的生命周期理解得很到位。以下是对线程生命周期和请求处理的详细解释:
Tomcat 线程生命周期
请求到达 :
当一个请求到达 Tomcat 服务器时,Tomcat 会从连接池中获取一个可用的线程来处理该请求。
线程执行 :
线程开始执行请求的处理逻辑。这可能涉及到调用控制器、服务、数据库访问等。
完成请求 :
一旦请求处理完成,线程会将响应返回给客户端。
此时,线程可以被标记为可用,重新返回到连接池中,等待下一个请求。
线程的生命周期 :
线程的生命周期与请求的生命周期是相关的。每个线程在处理请求期间是活跃的,完成后会变为可用状态,但线程本身并不会被销毁。
Tomcat 会保持一定数量的线程,以应对并发请求,通常在配置文件中进行设置。
使用 Redis 存储用户信息
在你提到的场景中,使用 Redis 存储 token 和用户信息的方式非常常见,通常具有以下优点:
状态管理 :
通过将用户信息(如用户对象)存储在 Redis 中,后续的请求可以依赖 token 从 Redis 中获取用户信息,而无需每次都访问数据库。这提高了性能和响应速度。
无状态性 :
使用 token 和 Redis 实现无状态的身份验证,符合微服务架构的原则。每个请求都是独立的,服务器不需要保存用户会话状态。
线程的短暂性 :
正如你所说,Tomcat 处理完请求后,线程会释放资源,而不是存储任何状态。后续请求可以使用相同的线程(从线程池中获取)来处理。
补充部分
token的生成的具体原理参照POE给出的解释:
头部(Header) :
通常包含两部分信息:令牌的类型(通常是 “JWT”)以及所使用的签名算法(如 HMAC SHA256 或 RSA)。
例如:1 2 3 4 { "alg" : "HS256" , "typ" : "JWT" }
有效载荷(Payload) :
这部分包含你想要传递的信息,称为声明(claims)。声明可以是注册声明(如 iat
、exp
、sub
等),也可以是自定义声明。
例如,如果你的输入只包含一对键值对,可以表示为:1 2 3 { "myKey" : "myValue" }
签名(Signature) :
为了防止数据被篡改,JWT 的最后一部分是签名。使用头部中指定的算法和密钥对头部和有效载荷进行签名。
签名的生成过程是:
将头部和有效载荷进行 Base64Url 编码。
连接这两部分,用点(.
)分隔。
使用指定的算法和密钥对连接字符串进行签名。
生成过程 :
1 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJteUtleSI6Im15VmFsdWUifQ.ABC123Signature