@TOC
拦截器配合JWT、ThreadLocal的登录校验
关于为什么要写这篇文章,今天在做项目的时候发现配置了拦截器,但是不生效,最后排查半天发现引入包有问题,遂决定写一篇详细的拦截器的使用。举例也都是根据案例写的,可能会有些许阅读困难,这里面的示例的TOKEN是在请求头里面的。
使用技术
- 拦截器Interceptor
- 令牌Jwt
- ThreadLocal
技术简介
拦截器:Spring Mvc提供的一种设计模式和编程结构,用于在程序的特定点(方法调用之前和调用之后)插入逻辑,以扩展或修改方法的基本行为。拦截器通常用于日志记录、权限检查、事务处理等场景。
JWT令牌:JWT是一种用于在网络上安全传输信息的令牌,通过数字签名的方式,以JSON对象为载体,在不同的服务终端之间安全地传输信息。
ThreadLocal:Java中的一个工具类,提供了线程局部变量的支持。它的主要功能是在多个线程之间隔离变量,使每个线程都拥有自己的变量副本,避免线程之间的数据共享和冲突。
JWT令牌
基本构成
JWT有三部分构成,由.
间隔。三个部分分别是请求头(Header)、有效负载(Payload)、签名(Signature)。例如eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Ind3dy5iZWpzb24uY29tIiwic3ViIjoiZGVtbyIsImlhdCI6MTcyOTQ5NjY2NywibmJmIjoxNzI5NDk2NjY3LCJleHAiOjE3Mjk1ODMwNjd9.bHGHTdcaQrcOJEv0F_pJ48_VBbboUtjQCKBcSdjN3r4
(这个JWT是从网站jwt解密/加密 - bejson在线工具拿到的一个示例数据)
请求头包含令牌的类型和签名所使用的算法(一般使用的是HS256,这个算法不是加密,可以编码和解码)。
例如上面的头存储的是
{
"alg": "HS256",
"typ": "JWT"
}
负载包含所需传递的数据或声明(Claims)。声明可以是关于实体(例如用户)及其权限的信息,或者是自定义信息。有效负载中的数据并没有加密,因此不应包含敏感信息。
例如上面的负载存储的是
{
"username": "www.bejson.com",
"sub": "demo",
"iat": 1729496667,
"nbf": 1729496667,
"exp": 1729583067
}
签名是用头部和有效负载的内容以及密钥创建,以防止数据篡改。签名的生成过程是将头部和有效负载进行编码,并用声明的算法和密钥进行加密。值得一提的是,签名也可以指定有效时间,从生成后多久后失效!
令牌密钥的保存方式
令牌密钥有很多种保存方式,但是不管用啥方式,都要保证密钥别丢了,常见的有这几种
- 直接写在类里(不安全,但是方便项目转移),直接定义就行,不举例了。
- 写在配置文件里面(如properties文件、yml文件,也不安全,方便转移),如果在yml中配置好了如下
jwt:
# 设置jwt签名加密时使用的秘钥
admin-secret-key: 密钥
# 设置jwt过期时间
admin-ttl: 时间(单位毫秒)
就可以在一个封装类中使用@ConfigurationProperties
注解获取,例如
@Component
@ConfigurationProperties(prefix = "jwt")
@Data
public class JwtProperties {
/**
* 管理端员工生成jwt令牌相关配置
*/
private String adminSecretKey;
private long adminTtl;
}
- 写在环境变量里面(和计算机绑在一起,相对安全),如果要写在环境变量里面运行cmd命令行,分别执行下面三条语句。
set JWT_SECRET=你想要的密钥
setx JWT_SECRET %JWT_SECRET%
echo %JWT_SECRET%
然后在Java中使用以下代码就能够获取了
String signKey = System.getenv(JWT_SSECRET);
Java中生成JWT令牌
使用之前自然是引入依赖(不同版本在实际定义的时候可能会有不同的写法)
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
Java中生成令牌和解析令牌,尤其是解析令牌,可能会非常常用,这种非常常用的方法就最好封装成工具类,到时候直接调用封装好的方法就可以了。提供一个封装好的JWT令牌生成方法:
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
public class JwtUtil {
/**
* 生成jwt
* 使用Hs256算法, 私匙使用固定秘钥
*
* @param secretKey jwt秘钥
* @param ttlMillis jwt过期时间(毫秒)
* @param claims 设置的信息
* @return
*/
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
// 指定签名的时候使用的签名算法,也就是header那部分
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 生成JWT的时间
long expMillis = System.currentTimeMillis() + ttlMillis;
Date exp = new Date(expMillis);
// 设置jwt的body
JwtBuilder builder = Jwts.builder()
// 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
.setClaims(claims)
// 设置签名使用的签名算法和签名使用的秘钥
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
// 设置过期时间
.setExpiration(exp);
return builder.compact();
}
/**
* Token解密
*
* @param secretKey jwt秘钥 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造, 如果对接多个客户端建议改造成多个
* @param token 加密后的token
* @return
*/
public static Claims parseJWT(String secretKey, String token) {
// 得到DefaultJwtParser
Claims claims = Jwts.parser()
// 设置签名的秘钥
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
// 设置需要解析的jwt
.parseClaimsJws(token).getBody();
return claims;
}
}
这个方法提供了生成TOKEN和解析TOKEN的方法,生成TOKEN调用JwtUtil.createJWT(密钥,过期时间,负载)
这个方法,解析TOKEN就调用JwtUtil.parseJWT(密钥,TOKEN)
这个方法。
下面是一个生成TOKEN的实例,使用的是令牌密钥保存方式的第二种
@PostMapping("/login")
@ApiOperation(value = "员工登录")
public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {
log.info("员工登录:{}", employeeLoginDTO);
Employee employee = employeeService.login(employeeLoginDTO);
//登录成功后,生成jwt令牌,没成功已经在service层抛出了自定义异常,结束了方法
Map<String, Object> claims = new HashMap<>();
claims.put(JwtClaimsConstant.EMP_ID, employee.getId());
String token = JwtUtil.createJWT(//主要是这里,这里生成了Token
jwtProperties.getAdminSecretKey(),
jwtProperties.getAdminTtl(),
claims);
EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder()//这个是封装返回给前端的数据了,把Token封装在了里面
.id(employee.getId())
.userName(employee.getUsername())
.name(employee.getName())
.token(token)
.build();
return Result.success(employeeLoginVO);
}
Java中解析JWT令牌
解析令牌用到的还是上面提到的工具类,放一个示例,具体是要在拦截器中实现的,并且加入userId变量到ThreadLocal中:
//2、校验令牌
try {
log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
log.info("当前员工id:{}", empId);
//3、通过,放行
return true;
} catch (Exception ex) {
//4、不通过,响应401状态码
response.setStatus(401);
return false;
}
ThreadLocal
ThreadLocal适用于以下环境:
- 多线程环境下,需要保证线程安全性的数据访问
- 多个方法之间需要共享数据,但又不希望使用传递参数的形式
- 常用语例如数据库链接、用户会话、线程上下文信息传递等
优点:
- 简化代码:提供了一种简单的方式来为每个线程存储和访问独立的变量,提高了代码的可读性和易维护性
- 避免并发问题:由于每个线程都有自己的变量副本,避免了并发修改和数据竞争问题
缺点:
- 内存泄漏:如果使用不当,例如线程没有及时调用remove(),可能会导致内存泄露,特别是在长生命周期的线程(线程池)中
- 调试困难:由于每个线程都有自己的副本,可能导致调试和测试变得更加复杂,难以追踪数据流动
- 不可恢复的状态:在某些情况下,线程中的数据是不可恢复的,一旦丢失,其他线程无法访问
简单实例
public class ThreadLocalTest {
//创建ThreadLocal对象
private static final ThreadLocal THREAD_LOCAL = new ThreadLocal();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
THREAD_LOCAL.set("这是第一个线程的数据");//设置局部变量的值
getData("t1");//获取局部变量的值
}, "t1");
Thread t2 = new Thread(() -> {
THREAD_LOCAL.set("这是第二个线程的数据");//设置局部变量的值
getData("t2");//获取局部变量的值
}, "t2");
//两个打印顺序不一定相同
t1.start();//打印结果:t1-这是第一个线程的数据
t2.start();//打印结果:t2-这是第二个线程的数据
}
private static void getData(String threadName){
Object data = THREAD_LOCAL.get();
System.out.println(threadName+"-"+data);
}
}
这个在实际开发中也是可以定义一个封装类的,里面写好set()、get()、remove()的方法,到时候直接调用就可以,不同再从定义一个ThreadLocal对象开始。
例如:
public class BaseContext {
public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
public static void setCurrentId(Long id) {
threadLocal.set(id);
}
public static Long getCurrentId() {
return threadLocal.get();
}
public static void removeCurrentId() {
threadLocal.remove();
}
}
拦截器
补充一点:拦截器和AOP是两个密切相关的概念,但是它们不是完全相同的东西。拦截器是一种设计模式,主要用于方法调用前后进行额外操作;AOP是一种编程范式,旨在通过将跨切关注点(如事务处理、日志记录、权限校验等)分离出来,从而提高代码的模块化程度。可以认为,拦截器是实现AOP的一个重要手段。AOP提供了一种更为全面的编程范式,涵盖了拦截器的使用以及切面、连接点、切入点等更多的概念。
拦截器的实现分三步:
- 自定义拦截器,实现拦截器接口HandlerInterceptor
- 将拦截器添加到容器中,一般是一个实现WebMvcConfigurer接口的配置类,这个类重写addInterceptors()方法
- 配置拦截器的拦截规则
自定义拦截器
写一个类,实现接口HandlerInterceptor,记得加上注解@Component
,里面的三个方法按需要重写
方法名 | 执行时机 |
---|---|
preHandle() | 方法在请求处理之前被调用,即在控制器方法执行之前。实现处理器的预处理(如登录检查) |
postHandle() | 方法在控制器方法执行之后,但在视图渲染之前调用。只要preHandle()返回true,这个一定执行。 |
afterCompletion() | 方法在视图渲染完成后调用,即整个请求处理流程的最后阶段。不管preHandle()返回true还是false,这个一定执行。 |
所以关键要重写的方法就是preHandle()
,如果用到了ThreadLocal
并且设置了线程变量,那么一定记得要在afterCompletion()
方法中使用remove()
把那个设置的值给删除,不然会内存泄露!
然后在这要注意一件事,如果你要判断拦截到的是Controller的方法或者是其它动态资源,会用到一个多态的判断,判断这个方法是不是动态方法,这里导入的包应该是org.springframework.web.method.HandlerMethod
,如果不是这个包,可能会出现一个问题,就是不管什么方法都放行!!这也是我写这个文章的原因,后面如果有多个包需要引入对应的我可能还会往上加,或者写新的笔记
示例:定义一个拦截器,进行令牌校验,然后在ThreadLocal里面设置好用户的ID,可以按需设置
/**
* jwt令牌校验的拦截器
*/
@Component
@Slf4j
public class JwtTokenAdminInterceptor implements HandlerInterceptor {
@Autowired
private JwtProperties jwtProperties;//前面JWT令牌的保存方式定义了
/**
* 校验jwt
*
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断当前拦截到的是Controller的方法还是其他资源
if (!(handler instanceof HandlerMethod)) {//一定要导对包,看上面的加粗提示
//当前拦截到的不是动态方法,直接放行
return true;
}
//1、从请求头中获取令牌
String token = request.getHeader(jwtProperties.getAdminTokenName());
//2、校验令牌
try {
log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
log.info("当前员工id:{}", empId);
//这个BaseContext在ThreadLocal的简单示例里面也定义了
BaseContext.setCurrentId(empId);//在这里给线程定义好了Id,被拦截器拦截的方法就可以直接获取Id了
//3、通过,放行
return true;
} catch (Exception ex) {
//4、不通过,响应401状态码
response.setStatus(401);
return false;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
BaseContext.removeCurrentId();//写上这个,防止内存泄漏!一定要写!
}
}
拦截器添加到容器中,同时定义拦截路径
这里采用的方式是一个实现WebMvcConfigurer
的接口的类,记得在类上加@Configuration
注解,声明这是一个配置类,重写里面的addInterceptors()
方法。
例如:这个只是一个举例,是单独的,再下面那个举例才是和上面的代码相关的
@Configuration
public class WebConfig implements WebMvcConfigurer {
//需要填充容器
@Autowired
private TokenInterceptor tokenInterceptor;
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tokenInterceptor)
.addPathPatterns("/**")//addPathPatterns是添加拦截路径,写什么具体参考下表
.excludePathPatterns("/login");//excludePathPatterns是排除路径,添加不需要拦截的路径
}
}
拦截路径 | 说明 |
---|---|
/** | 所有子路径,例如/user ,/user/login 都可以拦截 |
/* | 只拦截一级路径,例如可以拦截/user ,但是/user/login 就不能拦截 |
实际使用示例:
/**
* 配置类,注册web层相关组件
*/
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {
@Autowired
private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;
/**
* 注册自定义拦截器
* @param registry
*/
protected void addInterceptors(InterceptorRegistry registry) {
log.info("开始注册自定义拦截器...");
registry.addInterceptor(jwtTokenAdminInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/employee/login");//拦截所有非登录的管理员路径
}
到此为止,一个拦截器已经正式配置完成上线了,并且也在放行的动态方法的线程ThreadLocal中加入了ID,可以在前端不传递ID的情况下做一些和ID相关的操作了。
例如:我们要查询一个用户的所有信息,这个请求路径是/admin
的GET方法,通过了拦截器,这时候前端就不用传递给我们ID了,我们只需要在service
层使用如下方法就可以从数据库获取数据了
@Override
public List<Employee> showShoppingCart() {
return employeeMapper.getById(BaseContext.getCurrentId());//这个BaseContext的定义去看ThreadLocal里面的示例
}
补充一个指定拦截(指定拦截对一个路径的方法请求,例如同一路径放行GET,但是不放行POST等)
这个一般不常用,因为一般就用Spring Security
来做了,如果项目比较简单可以通过以下方式(使用了正则表达式)来实现,这里用到的示例是我以前写的一个简单的系统。
@Component
public class TokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String method = request.getMethod();//获取请求的方法
String path = request.getRequestURI();//获取请求的路径
//检查是否是可以直接放行的请求
if (isAllowedRequest(method, path)) {
return true;
} else {
//获得TOKEN
String token = request.getHeader("token");
//判断是否为空,空则返回401并结束方法
if (!StringUtils.hasLength(token)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
//解析TOKEN
try {
JwtUtils.parseJWT(token);
}catch (Exception e) {
//如果TOKEN过期或者错误也返回401
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
//走到这一步一定是正确的了
return true;
}
}
//用来判断接收的方法和路径是否是可以直接放行的
//我们在这放行了GET的/tickets路径请求,/tickets/1(这里是数字就行)路径请求,/comments路径请求;POST的/users/login路径请求,/users/register路径请求,/admins/login路径请求。
private boolean isAllowedRequest(String method, String path) {
if ("GET".equalsIgnoreCase(method)) {
return path.matches("^/tickets(/\\d+)?$") || path.matches("/comments");
} else if ("POST".equalsIgnoreCase(method)) {
return path.equals("/users/login") || path.equals("/users/register") || path.equals("/admins/login");
}
return false;
}
}
标签:令牌,拦截器,JAVA,JWT,线程,return,public
From: https://blog.csdn.net/2403_86693263/article/details/143117797