前言
本篇文章主要讨论 cookie、session、jwt
相关在 nestjs
中怎么使用的,简单介绍一下他们区别
如果想更详细了解他们区别,参考 here、there,这两篇也是我感觉不错的
简介
cookie、session、jwt
是我们平时用来鉴权、认证的部分手段,也是目前比较主流的手段(实际手段有很多,根据安全性自行设计的也有很多),主要目的为了快速确认用户身份,方便服务端进行后续工作(毕竟不能每次都传递用户名密码操作呀,这样既不方便也不安全)
cookie
:用于客户端的缓存策略,服务端给前端的用户凭证,里面保存着当前用户的一些信息,便于后台使用,后台不保存,前端丢失需要重新登陆获取,存在生效日期,前端拿到后,会自动保存到浏览器,下次在同域名请求是自动放到 headers 中,发送给服务端,就像是拿到了用户身份证复印件一样,可以迅速辨别身份,以进行后续处理。
其可能造成用户单方面的信息泄露,但也会减少服务端的压力
session
:用于服务端的一种缓存策略,将信息保存到服务器 session 中,将 id 发给用户,一般作为 cookie 的形式给用户,这样下次用户过来访问时,能快速根据 id 获取到用户信息。
用户数据不容易泄露了,但服务端压力变大了
jwt
:通过将用户信息使用秘钥进行加密,然后传递给客户端,让客户端保存,下次请求时携带上即可,服务端只需要使用秘钥解密,即可获得用户信息,这也是目前比较主流的方式
付出一些 cpu 的压力,避免信息泄露
token
:这个单纯的令牌模式,给用户另外配置一个信息,保存到客户端,客户端每次请求带上,服务端拿到 token 后,需要到数据库中查询对应的用户信息,比较传统
令牌
:上面说的给客户端用的一串信息,无论是加密、还是未加密,其都算是一个令牌,都是方便让服务器辨别身份用的,其他的都是手段,某种情况下,并不冲突,合理使用平衡才是关键
ps
:原始的用户信息就像我们本人,后续的 token 就像是给我们个体发的身份证,方便用户区分,你要是借给别人了,别人就可以利用你的身份干坏事了,所有才有了进一步的加密验证防三方篡改信息(在不暴露源码算法的基础上至少还是安全的)
session + cookie
nestjs 中我们使用的 cookie 比较常见的就是 session + cookie了,对服务器开销略大,需要牺牲内存和部分性能代价,对客户端则影响不大
js
//安装session库
yarn add express-session @types/express-session
添加到 main 函数中
js
import * as session from 'express-session';
app.use(
session({
//这里是我们的 secret,我用到了环境变量
secret: envConfig.secret,
//为true时,每次都更新session,不管改没改变,并不需要
resave: false,
//为true时,是默认给用户创建session,不管我们设置没设置,不需要
saveUninitialized: false,
})
);
配置完环境之后,就可以往 session 中添加内容了
js
loginWeb(
@Body() loginInfo: LoginDto,
//默认返回的是 SessionData 类型,可以添加数据
//设置成 any 很方便,但不符合我们ts规范,毕竟要是随便传递出错了也不好发现
//因此新增一个 CookieExtend 接口继承 SessionData 即可
@Session() session: CookieExtend,
) {
...
//我们往 session 中塞数据即可,这里塞了一个id,最好在server中写逻辑,这里仅仅案例
session.userId = auth.user.id
//设置严格模式
session.cookie.sameSite = 'strict' //严格模式
...
}
//定义的session数据类
export interface CookieExtend extends SessionData {
userId: number
}
这样调用我们的接口时,就可以会自动将 session 内容对应的 id,返回给用户当 cookie 了
当然我们也可以自己直接传递明文的 cookie,个人不太推荐哈,除非真的没有一点安全隐患,如果你说加密一下即可,实际上我们有更好用的 jwt
jwt
jwt
通过加、解密手段来处理令牌,方便了两端,唯一的缺陷是增加了cpu(又想安全,又不想有额外 cpu 消耗,做梦呢吧,怎么平衡看使用场合)
基础配置
js
//安装jwt
yarn add @nestjs/jwt
生成 guard 模块,用于鉴权,一般我们都会放到用户模块
js
我们使用 nest 指令生成一个 guard 模块,用于鉴权
nest g gu user
在我们的 module 中配置 jwt 相关,这里是 user.module,我们就放到这里即可
js
@Module({
imports: [
TypeOrmModule.forFeature([User]),
//全局设置jwt校验相关,放到我们对应的模块中
JwtModule.register({
global: true, //设置为全局
secret: envConfig.secret, //秘钥,我用的环境变量的参数,没写死
signOptions: {
expiresIn: '30d', //失效时长设置为30天
},
}),
],
})
给客户端生成令牌
使用 jwtService.sign
给用户返回 jwt 的 token 令牌
js
@Post('login')
login(
@Body() loginInfo: LoginDto,
) {
...这里的代码应该写到 service 中
let user = ...
if (!user) {
throw new UnauthorizedException()
}
//对我们的
let token = this.jwtService.sign({
id: user.id
})
let result = {
access_token: token, //将token返回给客户端,让客户端保存,放到 headers 中即可
user,
}
return ...
}
校验客户端传递的令牌
创建后我们的 guard
类后,发现继承自 CanActivate
,如下所示,我们只需要重写 canActivate
,实现校验逻辑即可
js
@Injectable()
export class UserGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private userService: UserService,
private reflector: Reflector,
) { }
async canActivate(
context: ExecutionContext,
): Promise<boolean> {
//获取请求,并校验token
const request = context.switchToHttp().getRequest();
let headers = request.headers
const token = headers.token
if (!token) {
throw new UnauthorizedException();
}
//验证token或解码获取信息
let { id } = this.jwtService.verify(token, {
secret: envConfig.secret,
});
if (!id) {
throw new UnauthorizedException();
}
// token验证后,获取用户信息,避免用户不存在了(被删除、封号)还能继续使用的情况,
// 最好设置维护黑、白名单之类的,然后根据 token 的期限,定时清理黑白名单即可
headers[USER_KEY] = { id }; //保存不变的用户令牌信息,可能不只是id,后续用户操作用这个会方便很多
// headers[USER_ID_KEY] = id; //也可以直接保存用户id,用户操作会经常用到
//如果想实现无感刷新,可以设置一个刷新token的接口,实现双token刷新,当然也可以前端定时间自己刷新
return true;
}
}
给接口添加校验
使用 @UseGuards
给接口添加校验,没写的默认没有
js
@Post('login')
@UseGuards(UserGuard) //后面的就是导入的 guard 的类,可以封装一个装饰来解决
login(
@Body() loginInfo: LoginDto,
) {
...
}
全局校验
上面的校验添加方式,比较适合用户操作比较少的,如果用户操作数据比较多的话就比较麻烦了,我们可以通过设置全局校验的方式,让所有接口都经过校验
在我们对应的 module
中的 providers
中添加下面的内容,就行了
js
providers: [
{
provide: APP_GUARD, //固定的
useClass: UserGuard, //设置我们的校验为全局校验,也就是每一个请求都要走我们的校验
},
],
疑问:这样真的就行了么? 我们有些接口不需要怎么办,我们注册接口还没有用户呀?
回答:马上安排,肯定少不了
配置一个全局装饰器和校验,让一些接口可以不校验,其中装饰器是为了我们方便标记,配合校验逻辑才能完成任务
ps :不了解装饰器可以参考 这里)
创建一个 decorator 类即可
js
//这样就创建了 user 的装饰器类
nest g d user
编写我们的逻辑,使用 SetMetadata
来设置我们的装饰器为 @Public()
,可以设置带参数的,这里就不需要了
typescript
import { SetMetadata } from '@nestjs/common';
export const PUBLIC_KEY = '__public_key';
export const Public = (...args: string[]) => SetMetadata(PUBLIC_KEY, args);
在接口上面标注一下 @Public()
就算是设置为公开,不需要校验了(我们还没写校验逻辑呢)
js
@Public()
@Post('login')
login(
@Body() loginInfo: LoginDto,
) {
...
}
重写 user.guard
的 canActivate
来完善我们的校验,前面写了一部分 jwt 的校验,我们这个应当在 jwt 校验前,如下所示,直接先判断是否是公开的,公开则不进行后续校验
js
async canActivate(
context: ExecutionContext,
): Promise<boolean> {
//获取我们的 Public 字段,方便
const isPublic = this.reflector.getAllAndOverride<boolean>(
PUBLIC_KEY,
[context.getHandler(), context.getClass()],
);
if (isPublic) {
return true;
}
//获取请求,并校验token
const request = context.switchToHttp().getRequest();
...
return true;
}
这样基本就完成了
扩展:装饰器
除了上面的装饰器,我们也可以扩展一些常用的装饰器,例如我们获取用户信息的,那个非全局校验的
我们重新封装一下 UseGuard 校验吧,让写法更固定,在我们的 decorator 中添加下面的即可
Guards装饰(自定义)
js
export const Guards = () => UseGuards(UserGuard)
使用如下所示
js
@Guards()
@Post('login')
login(
@Body() loginInfo: LoginDto,
) {
...
}
需要注意的是,使用局部的,需要去掉 module 中的全局应用
js
providers: [
UserService,
// { //局部使用 guard 时删除
// provide: APP_GUARD,
// useClass: UserGuard,
// },
],
ReqUser装饰(自定义)
前面发现我们校验时,会获取到我们保存的用户信息(用户令牌),既然获取了,那我们不妨保存起来,方便后续一些接口使用,我们设置一个枚举
js
...
//验证token或解码获取信息
let user = this.jwtService.verify(token, {
secret: envConfig.secret,
});
if (!user?.id) {
throw new UnauthorizedException();
}
headers[USER_KEY] = user; //保存用户id,后续用户操作会用到
...
设置 ReqUser 装饰,我们直接通过 Headers 获取我们的用户名称
js
//设置一个key,方便快速通过装饰器获取用户信息,在jwt验证时存入,这里获取
export const USER_KEY = '__user_key';
export const ReqUser = () => Headers(USER_KEY);
使用时如下所示,直接声明成我们的 User 类型即可
js
@ReqUser() user: User,
ps
:鉴权时正常我们不建议每次都访问数据库,这样每次掉接口会造成额外查询的开销,(可以通过维护黑白名单等方式解决,例如:使用高性能数据库维护名单,黑白名单操作不频繁,但访问频繁),必要时查询数据库才是最合适的,小项目懒得弄另说😂
用户黑名单校验
前面说的黑、白名单处理被删除用户的,我们可以维护一个黑名单,当用户被删除时将其加入黑名单,恢复时解除黑名单,jwt鉴权后,直接对比黑名单进行处理即可
之前想单独写一篇 redis,用这个做一个小案例扩展的,但是按照文档配置,到导入 module 这一步直接报错了,版本支持可能出现了问题,就先用mysql数据库 + 内存建立一个黑名单吧(毕竟用户删除、恢复操作并不频繁,主要还是访问内存的黑名单)
我们新建一个黑名单表,让其关联用户,当用户被软删除时,则记录在黑名单表格,内存中保存一份黑名单,用户访问时,对比内存即可,恢复后,表格删除黑名单,内存中也删除,初始化项目时记得根据数据库初始化一下内存的黑名单
js
@Entity('black_list') //默认带的 entity
export class BlackList {
@PrimaryGeneratedColumn()
id: number
//作为主键且创建时自动生成,默认自增
@Column({ unique: true, default: null })
userId: number
//创建时间,可以根据过期时间与其对比删除黑名单即可,token会自动过期
@CreateDateColumn()
createDate: Date
}
//可以在Service中加入即可
blackSet = new Set<number>()
//软删除用户后,加入黑名单,写入数据库,和set
this.blackSet.add(user.id)
black = new BlackList()
black.userId = user.id
await this.blackRepository.save(black)
//恢复用户,删除黑名单,移除 set 中的元素
await this.blackRepository.delete({
userId: user.id
})
this.blackSet.delete(user.id)
//项目初始化时,记得读取黑名单到set
let blackList = await this.blackRepository.find()
let set = this.blackSet
blackList.forEach(function(e) {
set.add(e.userId)
})
//guard鉴权时,加上判断即可
if (this.userService.blackSet.has(id)) {
throw new UnauthorizedException();
}
记得加上一个定时器,对比黑名单的事件定时清理哈
最后
希望大家看了能够有所收获😂
ps
:redis 在现在的 nestjs 不能直接用了,很难过,也间接说明了 nestjs 项目可能对 redis 可能没有那么依赖,大项目求稳还是使用主流的 java、python 等主流开发更稳妥一些,毕竟基数那里摆着😂