nestjscookie、session、jwt鉴权验证相关

2023年 9月 4日 101.2k 0

前言

本篇文章主要讨论 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了,对服务器开销略大,需要牺牲内存和部分性能代价,对客户端则影响不大

//安装session库
yarn add express-session @types/express-session

添加到 main 函数中

import * as session from 'express-session';

app.use(
    session({
        //这里是我们的 secret,我用到了环境变量
        secret: envConfig.secret,
        //为true时,每次都更新session,不管改没改变,并不需要
        resave: false,
        //为true时,是默认给用户创建session,不管我们设置没设置,不需要
        saveUninitialized: false,
    })
);

配置完环境之后,就可以往 session 中添加内容了

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 了

image.png

当然我们也可以自己直接传递明文的 cookie,个人不太推荐哈,除非真的没有一点安全隐患,如果你说加密一下即可,实际上我们有更好用的 jwt

jwt

jwt 通过加、解密手段来处理令牌,方便了两端,唯一的缺陷是增加了cpu(又想安全,又不想有额外 cpu 消耗,做梦呢吧,怎么平衡看使用场合)

基础配置

//安装jwt
yarn add @nestjs/jwt

生成 guard 模块,用于鉴权,一般我们都会放到用户模块

我们使用 nest 指令生成一个 guard 模块,用于鉴权
nest g gu user

在我们的 module 中配置 jwt 相关,这里是 user.module,我们就放到这里即可

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    //全局设置jwt校验相关,放到我们对应的模块中
    JwtModule.register({
      global: true, //设置为全局
      secret: envConfig.secret, //秘钥,我用的环境变量的参数,没写死
      signOptions: {
        expiresIn: '30d', //失效时长设置为30天
      },
    }),
  ],
})

给客户端生成令牌

使用 jwtService.sign 给用户返回 jwt 的 token 令牌

@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,实现校验逻辑即可

@Injectable()
export class UserGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
    private userService: UserService,
    private reflector: Reflector,
  ) { }
  
  async canActivate(
    context: ExecutionContext,
  ): Promise {
    //获取请求,并校验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 给接口添加校验,没写的默认没有

@Post('login')
@UseGuards(UserGuard) //后面的就是导入的 guard 的类,可以封装一个装饰来解决
login(
    @Body() loginInfo: LoginDto,
) {
    ...
}

全局校验

上面的校验添加方式,比较适合用户操作比较少的,如果用户操作数据比较多的话就比较麻烦了,我们可以通过设置全局校验的方式,让所有接口都经过校验

在我们对应的 module 中的 providers 中添加下面的内容,就行了

providers: [
    {
      provide: APP_GUARD, //固定的
      useClass: UserGuard, //设置我们的校验为全局校验,也就是每一个请求都要走我们的校验
    },
],

疑问:这样真的就行了么? 我们有些接口不需要怎么办,我们注册接口还没有用户呀?

回答:马上安排,肯定少不了

配置一个全局装饰器和校验,让一些接口可以不校验,其中装饰器是为了我们方便标记,配合校验逻辑才能完成任务

ps:不了解装饰器可以参考 这里)

创建一个 decorator 类即可

//这样就创建了 user 的装饰器类
nest g d user

编写我们的逻辑,使用 SetMetadata 来设置我们的装饰器为 @Public(),可以设置带参数的,这里就不需要了

import { SetMetadata } from '@nestjs/common';

export const PUBLIC_KEY = '__public_key';
export const Public = (...args: string[]) => SetMetadata(PUBLIC_KEY, args);

在接口上面标注一下 @Public() 就算是设置为公开,不需要校验了(我们还没写校验逻辑呢)

@Public()
@Post('login')
login(
    @Body() loginInfo: LoginDto,
) {
    ...
}

重写 user.guardcanActivate 来完善我们的校验,前面写了一部分 jwt 的校验,我们这个应当在 jwt 校验前,如下所示,直接先判断是否是公开的,公开则不进行后续校验

async canActivate(
    context: ExecutionContext,
  ): Promise {
     
    //获取我们的 Public 字段,方便
    const isPublic = this.reflector.getAllAndOverride(
      PUBLIC_KEY,
      [context.getHandler(), context.getClass()],
    );
    if (isPublic) {
      return true;
    }
    
    //获取请求,并校验token
    const request = context.switchToHttp().getRequest();
    
    ...

    return true;
  }

这样基本就完成了

扩展:装饰器

除了上面的装饰器,我们也可以扩展一些常用的装饰器,例如我们获取用户信息的,那个非全局校验的

我们重新封装一下 UseGuard 校验吧,让写法更固定,在我们的 decorator 中添加下面的即可

Guards装饰(自定义)

export const Guards = () => UseGuards(UserGuard)

使用如下所示

@Guards()
@Post('login')
login(
    @Body() loginInfo: LoginDto,
) {
    ...
}

需要注意的是,使用局部的,需要去掉 module 中的全局应用

providers: [
    UserService,
    // { //局部使用 guard 时删除
    //   provide: APP_GUARD,
    //   useClass: UserGuard,
    // },
  ],

ReqUser装饰(自定义)

前面发现我们校验时,会获取到我们保存的用户信息(用户令牌),既然获取了,那我们不妨保存起来,方便后续一些接口使用,我们设置一个枚举

...
//验证token或解码获取信息
let user = this.jwtService.verify(token, {
  secret: envConfig.secret,
});
if (!user?.id) {
  throw new UnauthorizedException();
}
headers[USER_KEY] = user; //保存用户id,后续用户操作会用到
...

设置 ReqUser 装饰,我们直接通过 Headers 获取我们的用户名称

//设置一个key,方便快速通过装饰器获取用户信息,在jwt验证时存入,这里获取
export const USER_KEY  = '__user_key';
export const ReqUser = () => Headers(USER_KEY);

使用时如下所示,直接声明成我们的 User 类型即可

@ReqUser() user: User,

ps:鉴权时正常我们不建议每次都访问数据库,这样每次掉接口会造成额外查询的开销,(可以通过维护黑白名单等方式解决,例如:使用高性能数据库维护名单,黑白名单操作不频繁,但访问频繁),必要时查询数据库才是最合适的,小项目懒得弄另说😂

用户黑名单校验

前面说的黑、白名单处理被删除用户的,我们可以维护一个黑名单,当用户被删除时将其加入黑名单,恢复时解除黑名单,jwt鉴权后,直接对比黑名单进行处理即可

之前想单独写一篇 redis,用这个做一个小案例扩展的,但是按照文档配置,到导入 module 这一步直接报错了,版本支持可能出现了问题,就先用mysql数据库 + 内存建立一个黑名单吧(毕竟用户删除、恢复操作并不频繁,主要还是访问内存的黑名单)

我们新建一个黑名单表,让其关联用户,当用户被软删除时,则记录在黑名单表格,内存中保存一份黑名单,用户访问时,对比内存即可,恢复后,表格删除黑名单,内存中也删除,初始化项目时记得根据数据库初始化一下内存的黑名单

@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()

//软删除用户后,加入黑名单,写入数据库,和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 等主流开发更稳妥一些,毕竟基数那里摆着😂

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论