前言:本文基于若依前后端分离版本(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、AuthenticationManagerSpring 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
/**
* 登录验证
*
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
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);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
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());
// 生成token
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 线程生命周期

  1. 请求到达

    • 当一个请求到达 Tomcat 服务器时,Tomcat 会从连接池中获取一个可用的线程来处理该请求。
  2. 线程执行

    • 线程开始执行请求的处理逻辑。这可能涉及到调用控制器、服务、数据库访问等。
  3. 完成请求

    • 一旦请求处理完成,线程会将响应返回给客户端。
    • 此时,线程可以被标记为可用,重新返回到连接池中,等待下一个请求。
  4. 线程的生命周期

    • 线程的生命周期与请求的生命周期是相关的。每个线程在处理请求期间是活跃的,完成后会变为可用状态,但线程本身并不会被销毁。
    • Tomcat 会保持一定数量的线程,以应对并发请求,通常在配置文件中进行设置。

使用 Redis 存储用户信息

在你提到的场景中,使用 Redis 存储 token 和用户信息的方式非常常见,通常具有以下优点:

  1. 状态管理

    • 通过将用户信息(如用户对象)存储在 Redis 中,后续的请求可以依赖 token 从 Redis 中获取用户信息,而无需每次都访问数据库。这提高了性能和响应速度。
  2. 无状态性

    • 使用 token 和 Redis 实现无状态的身份验证,符合微服务架构的原则。每个请求都是独立的,服务器不需要保存用户会话状态。
  3. 线程的短暂性

    • 正如你所说,Tomcat 处理完请求后,线程会释放资源,而不是存储任何状态。后续请求可以使用相同的线程(从线程池中获取)来处理。

补充部分

token的生成的具体原理参照POE给出的解释:

  1. 头部(Header)

    • 通常包含两部分信息:令牌的类型(通常是 “JWT”)以及所使用的签名算法(如 HMAC SHA256 或 RSA)。
    • 例如:
      1
      2
      3
      4
      {
      "alg": "HS256",
      "typ": "JWT"
      }
  2. 有效载荷(Payload)

    • 这部分包含你想要传递的信息,称为声明(claims)。声明可以是注册声明(如 iatexpsub 等),也可以是自定义声明。
    • 例如,如果你的输入只包含一对键值对,可以表示为:
      1
      2
      3
      {
      "myKey": "myValue" // login_user_key: loginUser.getToken()
      }
  3. 签名(Signature)

    • 为了防止数据被篡改,JWT 的最后一部分是签名。使用头部中指定的算法和密钥对头部和有效载荷进行签名。
    • 签名的生成过程是:
      • 将头部和有效载荷进行 Base64Url 编码。
      • 连接这两部分,用点(.)分隔。
      • 使用指定的算法和密钥对连接字符串进行签名。
  4. 生成过程

    • 头部与有效荷载分别进行编码,结果为(举例)
      eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
      eyJteUtleSI6Im15VmFsdWUifQ

    • 将编码后的头部和有效载荷用.连接起来:

      1
      eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJteUtleSI6Im15VmFsdWUifQ
    • 使用 HMAC SHA256 算法和密钥对连接字符串进行签名,得到的结果(假设为 signature):

      1
      ABC123Signature
    • 组合三部分,得到最终的 JWT:

    1
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJteUtleSI6Im15VmFsdWUifQ.ABC123Signature