spring:登录后逻辑:JWT、拦截器与ThreadLocal
JWT
做好网页登录了?为什么一刷新就要重新登录?如何才能像各大平台一样登录后一段时间之内不用重复登录?
也许你需要JWT令牌这个东西。
JWT 全名是 JSON Web Token,是一个用于在用户和服务器之间安全传递信息的东西,尤其常用于“登录后的身份认证”。
1 2 3
| eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJ1c2VySWQiOjEyMywidXNlcm5hbWUiOiJ4eXoifQ. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
|
它其实分三部分,用 .
分隔:
Header(头部):算法和类型
Payload(载荷):真正传递的用户信息,比如 userId、权限等级、过期时间等
Signature(签名):用来验证这个 token 是不是被伪造的
为什么说需要JWT呢?
我们为了让你的网站登录之后短期内不需要登录,我们可以把用户的信息浓缩进一个“卡片“内,就跟现在的NFC卡片一样,只要向机器上刷卡,机器就知道你是谁了。
JWT也同理,后端通过用户信息+过期时间+签名然后使用一些算法就可生成一段加密的文字。
当前端用户登录成功之后,后端把这个加密文字交给前端,前端执行某些操作:比如获取个人信息,点赞,评论的时候,把这一串加密的文字交给后端,后端拿着签名进行解密就获取了请求者的个人信息,于是就知道是谁发起请求的。
用法
添加依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>
|
工具类
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
| import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import java.security.Key; import java.util.Date;
public class JwtUtil { private static final long EXPIRE_TIME = 1000 * 60 * 60 * 24; private static final Key KEY = Keys.hmacShaKeyFor("1234567890abcdef1234567890abcdef".getBytes());
public static String createToken(String userId) { return Jwts.builder() .setSubject(userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); }
public static String getUserId(String token) { Claims claims = Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody(); return claims.getSubject(); }
public static boolean isExpired(String token) { try { Claims claims = Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody(); return claims.getExpiration().before(new Date()); } catch (Exception e) { return true; } } }
|
发送令牌
1 2 3 4 5 6
| @PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { String token = JwtUtil.createToken(dto.getUserId()); return Result.success(token); }
|
到这里,令牌已经交到前端的手里了,除了登录注册以外,用户一系列的行为:点赞,评论,收藏等等都需要带上他的令牌与后端交互,难道每一个接口都要检测一下用户有没有令牌吗?
显然不是,在Spring中,有一个名为拦截器的东西。
拦截器
拦截器,顾名思义,就是用来拦截的。
创建拦截器
1 2 3 4 5 6 7 8 9 10 11
| public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token == null || JwtUtil.isExpired(token)) { response.setStatus(401); return false; } return true; } }
|
形参
1. HttpServletRequest request 表示当前的请求
获取请求头、参数、URL、方法名:
1 2 3 4
| request.getHeader("Authorization") request.getRequestURI() request.getMethod() request.getParameter("username")
|
拿登录信息、IP、Session、Cookie 等等:
1 2 3
| request.getRemoteAddr() request.getSession() request.getCookies()
|
2. HttpServletResponse response
表示 当前响应,你可以用它:
1 2
| response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"error\": \"未登录\"}");
|
一般配合 return false
使用,用来终止请求继续向下执行
3. Object handler
写这篇文章的时候我也不太清楚,查了一下
- 判断被访问的方法是哪个 Controller 的哪个方法
- 拿到方法上的注解(比如自定义注解做权限控制)
1 2 3 4 5 6
| if (handler instanceof HandlerMethod) { HandlerMethod hm = (HandlerMethod) handler; Method method = hm.getMethod(); String methodName = method.getName(); MyAuth auth = method.getAnnotation(MyAuth.class); }
|
返回值
返回值是boolean,想必你已经知道答案了,为true的时候放行,false拦截。
配置拦截器
讲了这么多,是不是觉得少了点什么,拦截器该拦截什么请求,放行什么请求呢?
我们得给这个拦截器加一个配置才行。
1 2 3 4 5 6 7 8 9
| @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new JwtInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/register"); } }
|
代码示例
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
| import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.method.HandlerMethod;
public class JwtInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) { return true; }
String token = request.getHeader("Authorization");
if (token == null || token.isBlank()) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401, \"msg\":\"请先登录\"}"); return false; }
try { String userId = JwtUtil.getUserId(token); request.setAttribute("userId", userId); return true; } catch (Exception e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401, \"msg\":\"token 无效或已过期\"}"); return false; } } }
|
拿到密钥了,密钥正确,要让后端知道用户是谁,要把密钥解开
String userId = JwtUtil.getUserId(token);
上文已经解开了
用户ID什么时候才有用呢?
当然是和数据库做交互的时候,如果你的发表评论的逻辑是:
后端接收到令牌和评论信息,解读令牌,通过后在评论数据表中插入 用户ID + 评论数据
那么,Controller层需要拿到你的用户ID吗?
我们可以直接在Service层把用户ID+评论数据放到一个comment对象中,而不是通过request从Controller一层层往下传。
这时候,我们就需要用到
线程本地变量(ThreadLocal
)
Spring MVC 每个请求都会用一个线程处理,你可以用 ThreadLocal
在整个请求生命周期中共享变量:
- 登录拦截器里解析完 token,把用户 ID 存进去
- 后续任何地方(Service、Controller)都可以直接拿到当前用户 ID
你可以理解为:每一个请求都相当于一个平行宇宙,每一个平行宇宙中都有单独的ThreadLocal
工具类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public class UserContext { private static final ThreadLocal<String> USER_ID_HOLDER = new ThreadLocal<>(); public static void setUserId(String userId) { USER_ID_HOLDER.set(userId); } public static String getUserId() { return USER_ID_HOLDER.get(); } public static void clear() { USER_ID_HOLDER.remove(); } }
|
用法
存储用户ID
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
| public class JwtInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization");
if (token == null || token.isBlank()) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401, \"msg\":\"请先登录\"}"); return false; }
try { String userId = JwtUtil.getUserId(token); UserContext.setUserId(userId); return true; } catch (Exception e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401, \"msg\":\"token 无效或已过期\"}"); return false; } }
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserContext.clear(); } }
|
拿取用户ID
1 2 3 4 5 6 7 8
| @Service public class UserService {
public void getProfile() { String userId = UserContext.getUserId(); } }
|
像不像Terraria里的虚空袋
,Minecraft里的末影箱
以上,你完成了登录模块的一系列功能,你能够进行登录拦截,能够持久化登录,能够让当前请求的用户ID像放在末影箱里一样随取随用。