当前位置: 首页 > ai >正文

用户退出了Token还能用?用Nest+Redis给JWT令牌加黑名单!

前言

大家好,我是一诺。今天咱们来聊一个开发中特别头疼的问题——JWT 令牌的安全管理。

比如用户退出登录,或者账号被盗了,但之前发的令牌还能用,这就很尴尬了。不仅体验差,还可能被坏人钻空子,搞出安全问题
在这里插入图片描述

我们需要一种机制,可以撤销所有已颁发的令牌,防止进一步的未授权访问。经过实践,我发现使用Redis来实现令牌拉黑是一个既简单又高效的解决方案。

实现原理

Token拉黑机制的核心思想很简单:

  1. 维护一个"黑名单",存储已被撤销的token
  2. 每次请求来临时,除了验证token的签名和过期时间外,还要检查它是否在黑名单中
  3. 如果在黑名单中,则拒绝请求

在这里插入图片描述

听起来简单,但实际实现时需要考虑几个问题:

  • 黑名单存储在哪里?
  • 如何避免黑名单无限增长?
  • 如何确保黑名单查询足够快,不影响性能?

这就是Redis派上用场的地方。

为什么选择Redis?

Redis作为一个内存数据库,具有以下优势:

  1. 超高速查询:毫秒级的读写性能
  2. 键值过期机制:可以设置键的过期时间,过期后自动删除
  3. 原子操作:保证并发情况下的数据一致性
  4. 集群支持:可扩展性强,适合高并发场景

这些特性使Redis成为实现Token黑名单的理想选择。

具体实现

下面让我们看看项目中的实际代码实现。
在这里插入图片描述

1. Redis模块配置

首先,我们需要配置Redis连接。项目使用了ioredis客户端:

@Module({imports: [ConfigModule, AppConfigModule],providers: [{provide: REDIS_CLIENT,inject: [AppConfigService],useFactory: async (configService: AppConfigService) => {const logger = new Logger('RedisModule');const redisConfig = configService.redis;logger.log(`正在连接Redis: ${redisConfig.host}:${redisConfig.port}, db: ${redisConfig.db}`);const client = new Redis({host: redisConfig.host,port: redisConfig.port,retryStrategy(times) {if (times > redisConfig.retryAttempts) {logger.error(`Redis连接重试次数超过限制: ${times}/${redisConfig.retryAttempts},停止重试`);return null; // 停止重试}const delay = Math.min(times * redisConfig.retryDelay, 10000);logger.warn(`Redis连接失败,${delay}毫秒后重试 (${times}/${redisConfig.retryAttempts})`);return delay;}});// 监听连接事件client.on('connect', () => {logger.log('Redis连接已建立!');});client.on('ready', () => {logger.log('Redis连接就绪');});// 省略错误处理代码...return client;},},],exports: [REDIS_CLIENT],
})
export class RedisModule {}

2. TokenBlacklistService实现

这是整个机制的核心服务:

@Injectable()
export class TokenBlacklistService {private readonly blacklistPrefix = 'bl_token:';private readonly logger = new Logger(TokenBlacklistService.name);constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis,private readonly jwtService: JwtService,) {}/*** 将token加入黑名单* @param token JWT令牌* @param userId 用户ID* @returns 操作结果*/async addToBlacklist(token: string, userId: string): Promise<boolean> {if (!token) {this.logger.warn('尝试将空token加入黑名单');return false;}try {// 解析token获取过期时间const payload = this.jwtService.decode(token);if (!payload || typeof payload !== 'object' || !payload.exp) {this.logger.warn('无效token格式,无法解析过期时间');return false;}// 计算剩余过期时间(秒)const currentTimestamp = Math.floor(Date.now() / 1000);const ttl = Math.max(0, payload.exp - currentTimestamp);// 如果token已过期,无需加入黑名单if (ttl <= 0) {this.logger.debug('token已过期,无需加入黑名单');return true;}// 使用token的哈希值作为keyconst tokenHash = this.hashToken(token);const tokenKey = `${this.blacklistPrefix}${tokenHash}`;await this.redis.set(tokenKey, userId, 'EX', ttl);this.logger.debug(`Token已加入黑名单, 用户ID: ${userId}, 过期时间: ${ttl}`);return true;} catch (error) {const err = error as Error;this.logger.error(`将token加入黑名单失败: ${err.message}`);return false;}}/*** 检查token是否在黑名单中* @param token JWT令牌* @returns 是否在黑名单中*/async isBlacklisted(token: string): Promise<boolean> {if (!token) {return false;}try {const tokenHash = this.hashToken(token);const tokenKey = `${this.blacklistPrefix}${tokenHash}`;const exists = await this.redis.exists(tokenKey);return exists === 1;} catch (error) {const err = error as Error;this.logger.error(`检查token黑名单状态失败: ${err.message}`);// 发生错误时,默认允许请求通过,避免系统锁死return false;}}/*** 对token进行哈希处理,避免存储原始token* @param token JWT令牌* @returns token的哈希值*/private hashToken(token: string): string {// 使用SHA-256哈希算法处理tokenreturn crypto.createHash('sha256').update(token).digest('hex');}
}

3. JWT守卫集成

每个受保护的请求都会经过JWT守卫,我们在这里集成黑名单检查:

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {private readonly logger = new Logger(JwtAuthGuard.name);constructor(private reflector: Reflector,private tokenBlacklistService: TokenBlacklistService,) {super();}async canActivate(context: ExecutionContext): Promise<boolean> {// 检查是否标记为公共接口const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(),context.getClass(),]);if (isPublic) {return true;}try {// 执行标准JWT验证const canActivate = super.canActivate(context);const isValidJwt = canActivate instanceof Observable ? await firstValueFrom(canActivate) : await Promise.resolve(canActivate);// 如果JWT验证通过,检查token是否在黑名单中if (isValidJwt) {const request = context.switchToHttp().getRequest();const token = this.extractTokenFromHeader(request);// 如果找到token,检查是否在黑名单中if (token) {const isBlacklisted = await this.tokenBlacklistService.isBlacklisted(token);if (isBlacklisted) {this.logger.debug(`令牌已被撤销: ${this.maskToken(token)}`);throw new UnauthorizedException({code: ResponseCode.TOKEN_REVOKED,message: '登录已失效,请重新登录 TOKEN_REVOKED',});}}}return isValidJwt;} catch (error) {// 错误处理逻辑...}}// 提取请求头中的tokenprivate extractTokenFromHeader(request: any): string | null {const authHeader = request.headers.authorization;if (!authHeader || !authHeader.startsWith('Bearer ')) {return null;}return authHeader.substring(7); // 去掉'Bearer '前缀}
}

4. 登出实现

最后,在用户登出时,我们需要将当前token加入黑名单:

@UseGuards(JwtAuthGuard)
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: '用户退出登录' })
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {const refreshToken = req.cookies?.refreshToken;// 从请求上下文服务获取当前tokenconst accessToken = this.requestContextService.getAccessToken();const result = await this.authService.logout(req.user['_id'], refreshToken,accessToken);// 清除刷新令牌cookieif (refreshToken) {res.clearCookie('refreshToken');}return result;
}

在AuthenticationService中:

async logout(userId: string, refreshToken?: string, accessToken?: string): Promise<{ message: string }> {try {// 将当前访问令牌加入黑名单if (accessToken) {await this.tokenBlacklistService.addToBlacklist(accessToken, userId);this.logger.log(`用户 ${userId} 的访问令牌已加入黑名单`);}// 处理刷新令牌if (refreshToken) {await this.tokenService.invalidateRefreshToken(userId, refreshToken);}return { message: '已成功退出登录' };} catch (error) {this.logger.error(`退出登录过程中发生错误: ${(error as Error).message}`);return { message: '已成功退出登录' }; // 即使出错也返回成功,避免泄露系统错误}
}

设计要点解析

让我们分析一下这个实现中的几个关键设计:

1. 使用哈希值存储

我们没有直接存储完整的JWT令牌,而是存储其SHA-256哈希值。这样做有几个好处:

  • 安全性:即使Redis数据泄露,攻击者也无法获取原始token
  • 存储效率:哈希值长度固定,比原始token更节省空间
  • 查询效率:哈希查询比字符串比较更高效
private hashToken(token: string): string {return crypto.createHash('sha256').update(token).digest('hex');
}

2. 自动过期机制

我们巧妙地将Redis键的过期时间设置为与token剩余有效期相同:

// 计算剩余过期时间(秒)
const currentTimestamp = Math.floor(Date.now() / 1000);
const ttl = Math.max(0, payload.exp - currentTimestamp);// 如果token已过期,无需加入黑名单
if (ttl <= 0) {this.logger.debug('token已过期,无需加入黑名单');return true;
}await this.redis.set(tokenKey, userId, 'EX', ttl);

这样做的好处是:

  • 自动清理:过期的token会被Redis自动从黑名单中删除
  • 资源优化:不需要定期清理过期token的后台任务
  • 存储优化:黑名单大小不会无限增长

3. 失败默认允许

我们在处理Redis查询失败时采用了"失败默认允许"的策略:

catch (error) {const err = error as Error;this.logger.error(`检查token黑名单状态失败: ${err.message}`);// 发生错误时,默认允许请求通过,避免系统锁死return false;
}

这是一种权衡:

  • 优点:即使Redis服务不可用,系统仍能继续工作
  • 缺点:可能导致已撤销的token在Redis故障期间仍然有效

这种设计适合大多数应用场景,但对于安全要求极高的场景,可能需要改为"失败默认拒绝"。

4. 前缀设计

使用前缀区分不同类型的Redis键:

private readonly blacklistPrefix = 'bl_token:';

这种设计易于管理和调试,特别是在Redis实例被多个服务共享的情况下。

性能考虑

在高并发场景下,每个请求都要查询Redis可能会造成性能瓶颈。以下是一些可能的优化方案:

  1. 本地缓存:在应用服务器上维护一个小型内存缓存,缓存近期查询的token状态
  2. 批量操作:使用Redis的批量操作API减少网络往返
  3. 读写分离:使用Redis主从架构,读操作分布到多个从节点
  4. 集群部署:使用Redis集群分散压力

总结

通过Redis实现Token拉黑机制,我们巧妙地解决了JWT无法撤销的问题,同时保持了系统的高性能和可扩展性。这种实现不仅满足了用户登出和强制下线的需求,还为整个系统增加了一层额外的安全保障。

希望这篇文章对你有所帮助,有任何问题欢迎交流讨论!

拓展阅读

  • Redis官方文档
  • JWT官方介绍
  • OAuth2.0令牌撤销规范
http://www.xdnf.cn/news/10771.html

相关文章:

  • apisix + argorollout 实现蓝绿发布II-线上热切与蓝绿发布控制
  • 燃尽图和甘特图
  • 涨薪技术|0到1学会性能测试第93课-生产系统性能测试
  • LIMIT 和 OFFSET 在大数据量下的性能问题分析与优化方案
  • 动态规划-1143.最长公共子序列-力扣(LeetCode)
  • 【QT】自定义QWidget标题栏,可拖拽(拖拽时窗体变为normal大小),可最小/大化、关闭(图文详情)
  • Visual Studio Code
  • 自适应移动平均(Adaptive Moving Average, AMA)
  • Unity UI 性能优化--Sprite 篇
  • erase-remove idiom介绍
  • EtherCAT背板方案:方芯半导体工业自动化领域的高速、高精度的通信解决方案
  • 学习资料搜集-ARMv8 cache 操作
  • 704. 二分查找 (力扣)
  • 实践深度学习:构建一个简单的图像分类器
  • ORACLE 缺失 OracleDBConsoleorcl服务导致https://xxx:port/em 不能访问
  • 道可云人工智能每日资讯|北京农业人工智能与机器人研究院揭牌
  • 会议效率低下,应该怎么办
  • Linux 与 Windows:哪个操作系统适合你?
  • 飞腾D2000,麒麟系统V10,docker,ubuntu1804,小白入门喂饭级教程
  • 硬件工程师笔记——555定时器应用Multisim电路仿真实验汇总
  • React 基础语法
  • MySQL关系型数据库学习
  • Ubuntu24.04.2 + kubectl1.33.1 + containerdv1.7.27 + calicov3.30.0
  • C++ set数据插入、set数据查找、set数据删除、set数据统计、set排序规则、代码练习1、2
  • 【C/C++】template 入门到高阶简单大纲
  • rabbitMQ初入门
  • LangChain操作指南
  • 三、kafka消费的全流程
  • 6月2日day43打卡
  • 安全大模型的思考