Nest.js 服务端之使用 JWT 进行登录身份校验


一、JWT 相关

1.1 JWT 介绍

JSON Web Token ,简称 JWT ,一种基于 JSON 的认证授权机制,是一个非常轻巧的标准规范。这个规范允许我们在用户和服务器之间传递安全可靠的信息。

1.2 JWT 构成

头部(Header)

json 复制代码
{
  "alg": "HS256",
  "typ": "jwt"
}

通常由令牌的类型 typ 和所使用的签名算法 alg 组成,并使用Base64URL进行编码组成 JWT 结构的第一部分

载荷(Payload)

javascript 复制代码
{   
  "exp": Math.floor(Date.now() / 1000 + 60 * 60),  // 设置有效期为 1 个小时   
  "uid": "188888" 
}

除了设置过期时间 exp 和用户的 uid ,还可以添加自定义私有字段,使用 Base64URL 进行编码组成 JWT 结构的第二部分

签名(Signature)

less 复制代码
HMACSHA256(base64UrlEncode(Header) + "." + base64UrlEncode(payload), secret)
Header + Payload + secret 经过算法生成 Signature

在服务端设置一个私钥 secret ,使用 Header 指定的算法 HS256 对头部 Header 和 载荷 Payload 进行加密生成签名。

1.3 jwt 鉴权原理

1、客户端使用账号和密码请求登录

2、服务端收到请求,去验证账号与密码

3、验证通过后,服务端会签发一个令牌 Token 并把这个令牌发送给客户端

4、客户端收到令牌将其存储到本地 localStorage 中

5、客户端每次向服务端请求资源时都需要在 Header Authorization 中携带令牌,允许用户访问该令牌允许的路由、服务和资源

6、服务端收到请求后进行解密并通过秘钥验证令牌,验证通过返回接口资源,不通过则返回各类错误状态码,例如 401、403 等

1.4 jwt 特点

自包含(Self-contained)

JWT是一种自包含的令牌,它包含了所有用户身份验证所需的信息。令牌由三部分组成:头部(Header),有效载荷(Payload),和签名(Signature)。头部通常包含令牌的类型(即JWT)和所使用的加密算法;有效载荷包含声明(claims),它们是关于实体(通常是用户)和其他数据的声明;签名用于验证该令牌的发送者和防止内容被篡改。

1. 无状态和可扩展性

JWT设计为无状态的,服务器不需要保存令牌信息。这意味着一旦令牌被认证,服务器就可以理解和验证令牌,这使其在分布式系统中尤其有用,能够大幅减少服务器的运行负载,并提高可扩展性。

  • 由于服务器或 Session 中不会存储任何用户信息,所以基于 Token 的用户认证是一种服务器无状态的认证方式;
  • 没有会话信息意味着应用程序可以根据需要扩展和添加更多的机器(服务器集群),而不需要考虑用户是在哪一台服务器登录的,适用于 分布式系统;

2. 跨域和跨平台

JWT 适用于不同的编程环境,可以在多种域之间安全传输信息,非常适合单页应用(SPA)、移动应用和跨域认证。

  • 由于 Token 存储于应用系统,完全由应用系统管理,既减轻了服务端的内存压力也避开了同源策略的限制;
  • 这样就可以给任何域名提供 API 服务,不需要担心跨域资源共享问题,即 CORS;****

3. 易于扩展

  • 由于 JWT 存储在应用系统,服务端不进行会话存储,所以便于服务端集群水平扩展;
  • 可以在 JWT 中的载荷 Payload 部分添加自定义的内容用于业务需要;

4. 有效期限

JWT可以包含过期时间(Exp claim),这一特性可以用于限制令牌的有效期。这有助于减轻客户端时间和服务器时间不一致的问题,并允许令牌在一定时间后自动失效,不需要服务器进行额外的验证。


二、Nest.js 使用 JWT

Nest.js 接入 jwt

首先安装相关依赖

scss 复制代码
npm install --save passport-jwt @nestjs/jwt @nestjs/passport 

接入 jwt 配置

  • 分别创建 jwt.strategy.tsauth.service.tsauth.module.ts 模块文件

jwt.strategy.ts jwt 校验策略:

  • 需要继承PassportStrategy这个类方法
    • 这里如果能看到PassportStrategy这个类方法的实现的话,可以知道其实可以传递第二个name参数,这里先留个悬念,下面的章节当中我们再来看这个name参数有什么作用。
  • 需要使用super方法调用父类的构造函数进行 jwt 配置。
    • jwtFromRequest:提供从请求中提取 JWT 的方法。我们将使用在 API 请求的授权头中提供 token 的标准方法
    • ignoreExpiration:选择默认设置 false ,它将确保 JWT 没有过期的责任委托给 Passport 模块。这意味着,如果我们的路由提供了一个过期的 JWT ,请求将被拒绝,并发送 401 未经授权的响应
    • secretOrkey:使用权宜的选项来提供对称的密钥来签署令牌
    • validate:对于 JWT 策略,Passport 首先验证 JWT 的签名并解码 JSON 。然后调用 validate 方法,该方法将解码后的 JSON 作为其单个参数传递
  • 还需要实现validate属性方法
    • 参数是经过解析后的payload的对象数据。
    • return 返回的数据将会注入到@Req.user对象当中。
scala 复制代码
// jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: 'secret-key', // 加解密秘钥 key,后面 jwt register 也会使用到
    });
  }

  async validate(payload) {
    // 这里返回的数据会被注入到 @Req.user 对象内
    return payload;
  }
}

auth.service.ts token 相关操作:

  • 封装 createToken生成 token 和 verifyToken 校验 token 有效性的方法。
  • 将相关方法封装起来,方便别的服务注入直接调用使用。
kotlin 复制代码
// auth.service.ts

import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(private readonly jwtService: JwtService) {}

  // 生成 Token
  createToken(data) {
    return this.jwtService.sign(data);
  }

  // 校验 Token
  verifyToken(token) {
    if (!token) return '';
    
    return this.jwtService.verify(token);
  }
}

auth.module.ts

  • 配置验证相关模块,设置 jwt 加解密秘钥和 token 有效时间。
  • 引入、注入相关服务,对外抛出经过封装后的 AuthService 验证服务。
typescript 复制代码
// auth.module.ts

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';

import { AuthService } from '../services/auth.service';
import { JwtStrategy } from '../strategies/jwt.strategy';

const jwtModule = JwtModule.register({
  secret: 'secret-key',               // 加密 key
  signOptions: { expiresIn: '120h' }, // 过期时间 - 这里设置是 5 天
});

@Module({
  imports: [jwtModule, PassportModule],
  providers: [AuthService, JwtStrategy],
  exports: [jwtModule, AuthService],
})

export class AuthModule {}

登录颁发 token

判断用户登录获取到用户信息这部分就不说了(开发的各位应该都很清楚的了),这里就聚焦描述获取到用户信息后如何生成 jwt 并且返回。

ini 复制代码
  async login(name = '', password = '') {
    if (name && password) {
      const userInfo = await this.adminService.login(
        name,
        password,
      );

      if (userInfo) {
        userInfo.login_time = Date.now();

        const token = this.authService.createToken(userInfo);

        return { token, userInfo };
      }
    }

    throw {
      msg: '登录账号信息错误',
    };
  }
  • 这里使用的是我们前面创建的 auth.service.ts 里面封装的createToken方法
    • 其实就是使用@nestjs/jwt扩展的sign方法对用户信息使用加密秘钥 key 进行加密转换成字符串
  • 登录请求获取到 jwt 后进行端应用自行根据环境将 response 的 token 信息各自存储在对应合适的地方当中。
    • 设置本地 token 后就可以在请求前将该 token 设置到 request header 里面,交给服务端进行解析校验处理。
    • 接下来我们继续来看看服务端是如何对请求获取到的 jwt 进行解析校验呢?

守卫处理 token 校验

校验 jwt 可用

校验 token 的有效性,其实就是调用 jwtService.verify 方法就能够验证 token 的有效性。但是为了方便整体的 API 的一个访问流程生命周期模型,因此我们选择使用 守卫 Guard的方法来进行路由 API 访问时候进行自动校验 jwt token 的有效性。

创建auth.guard.ts守卫文件

typescript 复制代码
import {
  Injectable,
  ExecutionContext,
  HttpStatus,
  HttpException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

import { AuthService } from '../services/auth.service';

@Injectable()
export class AuthGuard extends AuthGuard('jwt') {
  constructor(private readonly authService: AuthService) {
    super();
  }

  async canActivate(context: ExecutionContext): Promise<any> {
    const req = context.switchToHttp().getRequest();

    try {
      const accessToken = req.get('Authorization');

      if (!accessToken) {
        throw new HttpException('请先登录', HttpStatus.FORBIDDEN);
      }

      const atUserId = this.authService.verifyToken(accessToken);

      if (atUserId) {
        return this.activate(context);
      }
    } catch (error) {
      if (error.status) throw error;
      return false;
    }
  }

  async activate(context: ExecutionContext): Promise<boolean> {
    return super.canActivate(context) as Promise<boolean>;
  }
}

请求接口使用守卫

这里守卫主要有三种不同场景的配置:

  1. 单个 API 路由配置
    • 使用
  1. 整个 Controller 下配置
  1. 整体应用的全局守卫配置

更多的 Nest.js 守卫的使用麻烦请移步到官方文档下面进行查阅,这里就不再累赘重复了。

结合着当前我们创建的 AuthGuard jwt 守卫,使用例子:

  • 这里就最简单的使用方法,在相关需要使用 jwt 登录的 API 接口当中使用@UseGuards装饰器对 API 接口定义进行守卫注入。
less 复制代码
@Put('password')
@UseGuards(AuthGuard)
setAdminPassword(
  @Req() req,
  @Body('password') password = '',
  @Body('confirmPassword') confirmPassword = '',
) {
  // TODO ...
}

经过这样子配置接口守卫之后,我们就能够实现请求自动进行用户登录的 token 的验证。

从 jwt 当中获取信息

在应用请求携带了相关的 token 信息之后,我们通过配置的请求守卫就会对该请求携带的 token 进行解析,如果通过之后就会在 req 对象里面挂载一个 user 新对象,这个 user 其实就是jwt.strategy.tsjwt 校验策略当中我们在validate当中 returrn 返回的数据。

  • 因此我们可以在jwt.strategy.tsvalidate方法当中获取到通过sign生成 jwt 时候设置的 payload 信息(例如 userId)再进行用户信息的查询获取,并且通过 return 返回设置到@Req.user当中。
  • 接着我们可以在请求方法内利用@Req装饰器获取到 req 对象进而获取到req.user,从而获取到登录用户的基本信息。

三、同时存在并区分两套 JWT

这个场景可能并不是所有人都遇到,毕竟大一点的项目可能就会干脆拆分多个项目或者微服务,但是当(公司小,资源省)只有一个 node 服务资源的情况下,并且还要区分多端渠道登录的情况下就会有这样的一个诉求了。

还记得上一章节当中我们在创建 jwt 策略时候提及到的一个name参数吗,就是这个参数能够让我们创建互不干扰的独立 jwt,供不同多端登录使用。

文件的拆分

拆分 B 端管理系统管理员登录和 C 端访客登录生成对应的 JWT 并且区分各自模块类别进行校验。

auth.module.tsjwt.strategy.tsauth.guard.ts这几个文件分别拆分 manage 和 client 两份各自独立的文件(文件命名可以根据实际进行添加对应的前缀)。

eg.

配置和逻辑的区分调整

首先就是 jwt 校验策略的区分,主要就是传递第二个参数name的不同:

  • 区分 BC 两端分别传入两个不同的 name 'client-jwt' 和 'manage-jwt'
typescript 复制代码
// client-jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'client-jwt') {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: 'client',
    });
  }

  async validate(payload: any) {
    return payload;
  }
}
typescript 复制代码
// manage-jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'manage-jwt') {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: 'manage',
    });
  }

  async validate(payload: any) {
    return payload;
  }
}

接着就是在对应的 AuthModule 内引入对应的 jwt 校验策略:

  • 记得 secret 调整对应策略内配置的对应 secretOrKey 的值
typescript 复制代码
// client-auth.module.ts

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';

import { AuthService } from '../services/auth.service';
import { JwtStrategy } from '../strategies/client-jwt.strategy';

// Client 端 JWT 配置
const jwtModule = JwtModule.register({
  secret: 'client',
  signOptions: { expiresIn: '72h' },
});

@Module({
  imports: [jwtModule, PassportModule],
  providers: [AuthService, JwtStrategy],
  exports: [jwtModule, AuthService],
})
export class ClientAuthModule {}
typescript 复制代码
// manage-auth.module.ts

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';

import { AuthService } from '../services/auth.service';
import { JwtStrategy } from '../strategies/manage-jwt.strategy';

// Manage 端 JWT 配置
const jwtModule = JwtModule.register({
  secret: 'manage',
  signOptions: { expiresIn: '120h' },
});

@Module({
  imports: [jwtModule, PassportModule],
  providers: [AuthService, JwtStrategy],
  exports: [jwtModule, AuthService],
})
export class ManageAuthModule {}

最后就是在相关不同的多端渠道使用对应的守卫进行 jwt token 有效性校验:

  • 这里是在不同的 Controller 内同样使用@UseGuards装饰器,只是分别引入对应端的 Jwt AuthGuard 逻辑。

区分 BC 端登录接口的守卫逻辑:

  • 这里拆分 guard 守卫时候要留意继承 AuthGuard 对象时需要传前面策略 strategy 所拆分传入区分的第二个参数。

这里因为是开荒开发介绍,系统还没完善和全面完整的账户验证校验,因此这里虽然拆分两套,但是目前守卫 Guard 的 jwt 校验逻辑还是基本上没区别;后续我们将结合着账号权限系统来改造账号验证守卫的处理。

typescript 复制代码
// client-auth.guard.ts

import {
  Injectable,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

import { AuthService } from '../services/auth.service';

@Injectable()
export class ClientAuthGuard extends AuthGuard('client-jwt') {
  constructor(private readonly authService: AuthService) {
    super();
  }

  async canActivate(context: ExecutionContext): Promise<any> {
    const req = context.switchToHttp().getRequest();
    try {
      const accessToken = req.get('Authorization');

      if (!accessToken) throw new UnauthorizedException('请先登录');

      const atUserId = this.authService.verifyToken(accessToken);
      
      if (atUserId) return this.activate(context);
    } catch (error) {
      return false;
    }
  }

  async activate(context: ExecutionContext): Promise<boolean> {
    return super.canActivate(context) as Promise<boolean>;
  }
}
typescript 复制代码
// manage-auth.guard.ts

import {
  Injectable,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

import { AuthService } from '../services/auth.service';

@Injectable()
export class ManageAuthGuard extends AuthGuard('manage-jwt') {
  constructor(private readonly authService: AuthService) {
    super();
  }

  async canActivate(context: ExecutionContext): Promise<any> {
    const req = context.switchToHttp().getRequest();
    try {
      const accessToken = req.get('Authorization');

      if (!accessToken) throw new UnauthorizedException('请先登录');

      const atUserId = this.authService.verifyToken(accessToken);
      
      if (atUserId) return this.activate(context);
    } catch (error) {
      return false;
    }
  }

  async activate(context: ExecutionContext): Promise<boolean> {
    return super.canActivate(context) as Promise<boolean>;
  }
}

分别在对应的控制器当中引入对应 BC 端的守卫:

less 复制代码
// client.controller.ts

import { ClientAuthGuard } from '../guards/client-auth.guard';


@Controller('client')
export class ClientController {
  constructor(private readonly clientService: ClientService) {}

  @Post('visitor/info')
  @UseGuards(ClientAuthGuard)
  setVisitorInfo(@Req() req, @Body() body: VisitorInfo) {
    return this.clientService.setVisitorInfo(req.user._id, body);
  }
}
less 复制代码
// manage.controller.ts

import { ManageAuthGuard } from '../guards/manage-auth.guard';

@Controller('manage')
export class ManageController {
  constructor(private readonly manageService: ManageService) {}

  @Put('admin/:adminId')
  @UseGuards(ManageAuthGuard)
  updateAdminInfo(@Param('adminId') adminId = '', @Body() adminInfo: AdminInfo) {
    return this.manageService.updateAdminInfo(adminId, adminInfo);
  }
}

好了,至此,我们已经能够实现了不同渠道都能各自有对应的 jwt 登录歌颁发和生成、校验的最基础功能了。

但是除了用户登录状态的 token 校验以外,在后台管理系统当中用户的权限是必不可少的一样东西,下一期我们将会结合着 RBAC 来探讨下 nest.js 当中如何对接口进行权限判断,敬请期待吧。


参考资料

相关推荐
纸月6 分钟前
短信服务(二):实现某短信服务商崩溃后自动切换并重试
后端·go
不露声色丶13 分钟前
elementPlus表格二次封装
前端·javascript·vue.js
王天乐00717 分钟前
ElementUI的搭建
前端·javascript·elementui
OpenTiny社区22 分钟前
7月6日 VueConf 技术大会即将在深圳举办
前端·vue.js·github
隐藏用户y25 分钟前
探索如何赋予对象迭代魔法,轻松实现非传统解构赋值的艺术
前端·javascript
Zww089133 分钟前
css美化滚动条样式
前端·css·css3
Goat恶霸詹姆斯33 分钟前
uniapp实现图片懒加载 封装组件
前端·javascript·uni-app
佩淇呢37 分钟前
uniapp 使用vite构建项目
前端·vue.js·uni-app
专注成就自我37 分钟前
p标签文本段落中因编辑器换行引起的空格问题完美解决方案
前端·javascript·vue.js·编辑器·html