nestjs-cookie、session、jwt鉴权验证相关

前言

本篇文章主要讨论 cookie、session、jwt 相关在 nestjs 中怎么使用的,简单介绍一下他们区别

如果想更详细了解他们区别,参考 herethere,这两篇也是我感觉不错的

简介

cookie、session、jwt 是我们平时用来鉴权、认证的部分手段,也是目前比较主流的手段(实际手段有很多,根据安全性自行设计的也有很多),主要目的为了快速确认用户身份,方便服务端进行后续工作(毕竟不能每次都传递用户名密码操作呀,这样既不方便也不安全)

cookie:用于客户端的缓存策略,服务端给前端的用户凭证,里面保存着当前用户的一些信息,便于后台使用,后台不保存,前端丢失需要重新登陆获取,存在生效日期,前端拿到后,会自动保存到浏览器,下次在同域名请求是自动放到 headers 中,发送给服务端,就像是拿到了用户身份证复印件一样,可以迅速辨别身份,以进行后续处理。

其可能造成用户单方面的信息泄露,但也会减少服务端的压力

session:用于服务端的一种缓存策略,将信息保存到服务器 session 中,将 id 发给用户,一般作为 cookie 的形式给用户,这样下次用户过来访问时,能快速根据 id 获取到用户信息。

用户数据不容易泄露了,但服务端压力变大了

jwt:通过将用户信息使用秘钥进行加密,然后传递给客户端,让客户端保存,下次请求时携带上即可,服务端只需要使用秘钥解密,即可获得用户信息,这也是目前比较主流的方式

付出一些 cpu 的压力,避免信息泄露

token:这个单纯的令牌模式,给用户另外配置一个信息,保存到客户端,客户端每次请求带上,服务端拿到 token 后,需要到数据库中查询对应的用户信息,比较传统

令牌:上面说的给客户端用的一串信息,无论是加密、还是未加密,其都算是一个令牌,都是方便让服务器辨别身份用的,其他的都是手段,某种情况下,并不冲突,合理使用平衡才是关键

ps:原始的用户信息就像我们本人,后续的 token 就像是给我们个体发的身份证,方便用户区分,你要是借给别人了,别人就可以利用你的身份干坏事了,所有才有了进一步的加密验证防三方篡改信息(在不暴露源码算法的基础上至少还是安全的)

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.guardcanActivate 来完善我们的校验,前面写了一部分 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 等主流开发更稳妥一些,毕竟基数那里摆着😂

相关推荐
Eric_见嘉4 天前
NestJS 🧑‍🍳 厨子必修课(九):API 文档 Swagger
前端·后端·nestjs
XiaoYu200212 天前
第3章 Nest.js拦截器
前端·ai编程·nestjs
XiaoYu200214 天前
第2章 Nest.js入门
前端·ai编程·nestjs
实习生小黄14 天前
NestJS 调试方案
后端·nestjs
当时只道寻常17 天前
NestJS 如何配置环境变量
nestjs
濮水大叔1 个月前
VonaJS是如何做到文件级别精确HMR(热更新)的?
typescript·node.js·nestjs
ovensi1 个月前
告别笨重的 ELK,拥抱轻量级 PLG:NestJS 日志监控实战指南
nestjs
ovensi1 个月前
Docker+NestJS+ELK:从零搭建全链路日志监控系统
后端·nestjs
Gogo8161 个月前
nestjs 的项目启动
nestjs