【Nest.js 10】JWT+Redis实现登录互踢

前言

JWT颁发的token只能在获取时设置过期时间,是没有办法在过期时间内踢出登录(脚本偷数据,多台电脑登录同一个VIP账号),也没有办法自动续期(在使用时会掉线)。因此Redis+token是一种可行的登录方案。

本文基于【Nest.js 10】权限管理-登录认证JWT、权限划分,建议先阅读上篇文章,再阅读本文章。

1. 什么是 Redis

Redis是一个开源的,基于内存的数据结构存储系统,它可以用作数据库、缓存和消息中间件,是现在最受欢迎的 NoSQL 数据库之一。

高速读写:Redis 默认将数据存储在内存中,这使得它能够提供非常快的数据读写速度。尽管它也支持将数据持久化到磁盘,但这主要用于数据备份和恢复,而不是作为主要的存储方式。

多种数据结构支持:Redis 不仅仅是一个简单的键值存储系统,还支持多种数据结构,如字符串、哈希、列表、集合等。

缓存功能:Redis 可以用作缓存层,提供了快速、高效的读取和写入操作。它可以将经常访问的数据存储在内存中,从而避免了频繁地访问较慢的数据库。这样可以大大降低系统的负载并提升响应速度。

2. 安装Redis

Windows下载地址:github.com/MicrosoftAr...,下载zip文件

点击redis-server.exe文件启动Redis

3. Redis的基本数据类型

redis是一种key-value非关系型数据库。其中value支持五种数据类型:string,hash,List,set,zset。

点击redis-cli.exe文件启动Redis命令行界面

1. string

string存储的元素类型可以是string/int/float,int类型可以进行增加和减少操作。

应用场景:共享session、分布式锁,计数器、限流。

1. 设置和获取

js 复制代码
set key value 
get key

2. 增量

js 复制代码
incr key  //将 key 中储存的数字值增一,incr是increase的缩写
incrby key increment //将 key 中储存的数字值加上 increment

3. 递减

js 复制代码
decr key // 将 key 中储存的数字值减一。
decrby key decrement // 将 key 中储存的数字值减去 decrement。

2. hash

hash类型也叫散列类型,存储的时候存的是键值对(对象)

应用场景:缓存用户信息等。

1. 设置和获取

js 复制代码
hset key field value //key是对象名称,field是字段名称,value是字段值
hget key field

2. 批量操作

js 复制代码
hmset key field1 value1 [field2 value2 ...] //同时设置一个或多个哈希表字段的值
hmget key field1 [field2 ...] //获取所有(一个或多个)给定字段的值。

3. 其他操作

js 复制代码
hdel key field1 [field2 ...] //删除一个或多个哈希表字段。
hkeys key //获取哈希表中所有字段。
hvals key //获取哈希表中所有值。
hlen key //获取哈希表中字段的数量

3. list

list类型是一个有序的列表,有序表示的是从左到右还是从右到左,而且数据内容是可以重复的。

应用场景:消息队列,文章列表

js 复制代码
lpush key value [value2] //将一个或多个值插入到列表头部
lpop key //移除并返回列表的第一个元素
lrange key start end //返回列表中指定区间内的元素
lset key index value //对列表中的指定位置的元素进行更新

除了从左开始,还可以从右开始,指令以r开头

4. set

set类型是一个无序列表,每个元素的值不一样。用户可以快速对元素中的值添加删除,检查某些值是否存在,重复的元素是无法继续插入集合的

应用场景:用户标签,生成随机数抽奖、社交需求。

js 复制代码
sadd key member1 [member2] //向集合添加一个或多个成员
srem key member1 [member2] //移除集合中的一个或多个成员
scard key //返回集合中的个数
smembers key //返回集合中的所有成员
sismember key member//判断 member 元素是否是集合 key 的成员

5. zset

sore set也叫有序分数集,可以把它看作一个排行榜,每一个同学都有自己的分数,且排行榜中还有一个排名的属性,排行属性从0,根据分数不断变大,排行也不断变大。

特性:

1)sore set中的值是全局唯一的。 一个值设置了之后,再次设置不会增加,只会覆盖修改。

2)如果有两条分数相同,会根据值两个元素变量名的字典排序顺序排列先后

应用场景:排行榜,社交需求(如用户点赞)

1. 增加元素

js 复制代码
zaddkey key score1 member1 [score2 member2] //将一个或多个成员元素及其分数值加入到有序集合当中

2. 删除元素

js 复制代码
zrem key member1 [member2] //移除有序集合中的一个或多个成员

3. 其他操作

js 复制代码
zrange key start stop [WITHSCORES] //返回有序集合中指定区间内的成员
zcard key //统计当前key下值的个数
zscore key member //返回有序集合中指定成员的分数值

4. 可视化插件

  1. 在Vscode中搜索 Database Client 并安装

  2. 安装完成后,选择左侧数据库,点击创建连接,这里选择Redis连接(用户名与密码默认未设置,直接连接即可)

  1. 然后我们就可以看到Redis中缓存的数据了

与SQL型数据不同,redis没有提供新建数据库的操作,因为它自带了16(0-15)个数据库(默认使用0库)。

提示:默认端口+无密码只适合本地学习,在公司网络或自己的服务器上需更改Redis的端口和密码

4. 在Nest中使用

提示:本来想采用cache-manager+cache-manager-redis-store方案来进行存储,但cache-manager-redis-store更新太慢,导致类型校验跟不上,故舍弃该方案。直接采用redis

  1. 安装
js 复制代码
npm i redis
  1. 对 redis 的操作单独建一个模块
js 复制代码
nest g mo core/redisCache
nest g s core/redisCache
  1. 在redis-cache.module中导入Redis
js 复制代码
import { Global, Module } from '@nestjs/common';
import { createClient } from 'redis';

import { RedisCacheService } from './redis-cache.service';

@Global()
@Module({
  imports: [],
  providers: [
    {
      provide: 'REDIS_CLIENT',
      async useFactory() {
        const client = createClient({
          socket: {
            host: 'localhost',
            port: 6379,
          },
        });
        await client.connect();
        return client;
      },
    },
    RedisCacheService,
  ],
  exports: [RedisCacheService],
})
export class RedisCacheModule {}
  1. 在redis-cache.service通过Inject注入REDIS_CLIENT,然后写写一下操作 redis 的方法
js 复制代码
import { Inject, Injectable } from '@nestjs/common';
import { RedisClientType } from 'redis';

@Injectable()
export class RedisCacheService {
  constructor(@Inject('REDIS_CLIENT') private redisClient: RedisClientType) {}
  async get(key) {
    let value = await this.redisClient.get(key);
    try {
      value = JSON.parse(value);
    } catch (error) {}
    return value;
  }
  async set(key: string, value: any, second?: number) {
    value = JSON.stringify(value);
    return await this.redisClient.set(key, value, { EX: second });
  }
  async del(key: string) {
    return await this.redisClient.del(key);
  }
  //清空所有数据库中的所有键
  async flushAll() {
    return await this.redisClient.flushAll();
  }
}

redies包含多种数据格式,这里只演示string类型的使用,如果不是string类型,先使用JSON.stringify转换

5. 与token结合使用

1. token过期处理

不设置过期时间,JWT将永不过期,由redis来控制过期时间

  1. 修改user.module.ts,去掉过期时间
js 复制代码
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';

import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  imports: [
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (config: ConfigService) => ({
        secret: config.get('jwt.secretkey'),
        signOptions: { expiresIn: config.get('jwt.expiresin') }, //删除
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}
  1. 修改user.service.ts,在登录时使用redis存储token
js 复制代码
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { RedisCacheService } from 'src/core/redis-cache/redis-cache.service';
@Injectable()
export class UserService {
  constructor(
    private readonly jwtService: JwtService,
    private redisCacheService: RedisCacheService,
  ) {} //生成令牌
  async createToken(payload: {
    username: string;
    userId: string;
  }): Promise<string> {
    //jwt会使用secret中配置的密钥,对payload进行加密,从而生成token
    //这里根据用户名与用户ID进行加密,生成token
    const accessToken = this.jwtService.sign(payload);
    //将token存储到redis中
    await this.redisCacheService.set(
      `${payload.username}&${payload.userId}`,
      accessToken,
      60 * 60 * 24,
    );
    return accessToken;
  }
}
  1. 修改user.controller.ts

由于createToken函数从同步变为异步,因此需使用await取值

js 复制代码
 //...
@Post('/login')
  async login(@Body() params) {
    //可先从数据库中查询用户,将用户信息作为载荷,生成token
    const user = { userId: '1', username: '张三', role: 2 };
    const accessToken = await this.userService.createToken(user); //增加await
    return {
      accessToken,
    };
  }
  1. 修改auth.strategy.ts,验证token是否过期

在验证token时, 从redis中取token,如果取不到token,可能是token已过期。

js 复制代码
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RedisCacheService } from 'src/core/redis-cache/redis-cache.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly config: ConfigService,
    private redisCacheService: RedisCacheService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), //验证token是否正确,正确则调用自定义validate函数
      ignoreExpiration: false,
      secretOrKey: config.get('jwt.secretkey'),
    });
  }
  async validate(payload) {
    //从redis中取对应的token
    const cacheToken = await this.redisCacheService.get(
      `${payload.username}&${payload.userId}`,
    );
    //取不出来,说明已过期
    if (!cacheToken) {
      throw new UnauthorizedException('token 已过期');
    }
    return payload;
  }
}

2. 用户唯一登录

由于当用户登录时,每次签发的新的token,会覆盖之前的token, 判断redis中的token与请求传入的token是否相同, 不相同时, 可能是其他地方已登录, 提示token错误。

js 复制代码
//...
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly config: ConfigService,
    private redisCacheService: RedisCacheService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: config.get('jwt.secretkey'),
      passReqToCallback: true, //1.validate函数第一个参数返回req请求对象
    });
  }
  async validate(req, payload) {
    //2.从请求头中获取token
    const token = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
    const cacheToken = await this.redisCacheService.get(
      `${payload.username}&${payload.userId}`,
    );
    if (!cacheToken) {
      throw new UnauthorizedException('token 已过期');
    }
    //3.不同的话,则在其他地方登录
    if (token != cacheToken) {
      throw new UnauthorizedException('在其他地方登录');
    }
    return payload;
  }
}

3. token自动续期

token自动续期通常有两种方式。

方案一,长短token:后台jwt生成access_token(jwt有效期30分钟)和refresh_token, refresh_token有效期比access_token有效期长,客户端缓存此两种token, 当access_token过期时, 客户端再携带refresh_token获取新的access_token。可参考我的另一篇文章:无感刷新token(从后端到前端整个流程)

方案二,Redis:

①:jwt生成token时,有效期设置为永不过期

②:redis 缓存token时设置有效期30分钟

③:用户携带token请求时, 如果key存在,且value相同, 则重新设置有效期为30分钟

js 复制代码
async validate(req, payload) {
      const token = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
      const cacheToken = await this.redisCacheService.get(
        `${payload.password}&${payload.userName}`,
      );
      if (!cacheToken) {
        throw new UnauthorizedException('token 已过期');
      }
  
      if (token != cacheToken) {
        throw new UnauthorizedException('在其他地方登录');
      }
  
   +   this.redisCacheService.set(
        `${payload.userName}&${payload.password}`,
        token,
        60 * 60 * 24,
      );
      return payload;
    }

参考文章:Nest.js进阶系列五: Node.js中使用Redis原来这么简单

相关推荐
苏三说技术7 小时前
推荐一个牛逼的RAG+KAG双引擎AI项目
后端
格子软件8 小时前
2026年GEO优化系统源码级状态机与多模型调度拆解
java·前端·vue.js·人工智能·vue·geo
从此以后自律8 小时前
Spring 全家桶
java·后端·spring
HUMHSX8 小时前
Vue 项目启动全流程解析:从入口文件到全局指令注册与页面渲染
前端·javascript·vue.js
有颜有货9 小时前
PMC生产排产的4种算法,一次讲清
java·服务器·前端
小虎牙0079 小时前
Android kotlin图片库Coil源码详解
android·前端
随风一样自由9 小时前
【前端领域】前端开发核心应用场景与落地实践
前端·前端框架
an317429 小时前
弹窗数据流设计的两种高阶架构实践
前端·vue.js·架构
utmhikari9 小时前
【日常随笔】深入回答纯Vibe Coding写后端项目的几个问题
后端·ai编程·vibecoding
尚早立志9 小时前
Spring Boot 源码研读之ConfigurableEnvironment 环境准备
java·spring boot·后端