【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原来这么简单

相关推荐
方才coding3 分钟前
2024最新的开源博客系统:vue3.x+SpringBoot 3.x 前后端分离
spring boot·后端·开源·博客系统·前后端分离·个人博客·vue 3.x
颜淡慕潇14 分钟前
【K8S系列】Kubernetes 中 Service 的流量不均匀问题【已解决】
后端·云原生·容器·kubernetes
weixin_5168756522 分钟前
使用 axios 拦截器实现请求和响应的统一处理(附常见面试题)
前端·javascript·vue.js
_oP_i25 分钟前
Unity 中使用 WebGL 构建并运行时使用的图片必须使用web服务器上的
前端·unity·webgl
H_HX12628 分钟前
https服务器访问http资源报Mixed Content混合内容错误
前端·vue.js·安全策略·https访问http
羊小猪~~36 分钟前
前端入门一之CSS知识详解
前端·javascript·css·vscode·前端框架·html·javas
ReBeX42 分钟前
【GeoJSON在线编辑平台】(1)创建地图+要素绘制+折点编辑+拖拽移动
前端·javascript·gis·openlayers·webgis
阿宝分享技术1 小时前
从xss到任意文件读取
前端·xss
今天也想MK代码1 小时前
在Swift开发中简化应用程序发布与权限管理的解决方案——SparkleEasy
前端·javascript·chrome·macos·electron·swiftui
宝子向前冲1 小时前
纯前端生成PDF(jsPDF)并下载保存或上传到OSS
前端·pdf·html2canvas·oss·jspdf