前言
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. 可视化插件
-
在Vscode中搜索 Database Client 并安装
-
安装完成后,选择左侧数据库,点击创建连接,这里选择Redis连接(用户名与密码默认未设置,直接连接即可)
- 然后我们就可以看到Redis中缓存的数据了
与SQL型数据不同,redis没有提供新建数据库的操作,因为它自带了16(0-15)个数据库(默认使用0库)。
提示:默认端口+无密码只适合本地学习,在公司网络或自己的服务器上需更改Redis的端口和密码
4. 在Nest中使用
提示:本来想采用cache-manager+cache-manager-redis-store
方案来进行存储,但cache-manager-redis-store
更新太慢,导致类型校验跟不上,故舍弃该方案。直接采用redis
- 安装
js
npm i redis
- 对 redis 的操作单独建一个模块
js
nest g mo core/redisCache
nest g s core/redisCache
- 在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 {}
- 在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来控制过期时间
- 修改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 {}
- 修改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;
}
}
- 修改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,
};
}
- 修改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;
}