一、JWT 相关
1.1 JWT 介绍
JSON Web Token ,简称 JWT ,一种基于 JSON 的认证授权机制,是一个非常轻巧的标准规范。这个规范允许我们在用户和服务器之间传递安全可靠的信息。
1.2 JWT 构成
头部(Header)
{
"alg": "HS256",
"typ": "jwt"
}
通常由令牌的类型 typ 和所使用的签名算法 alg 组成,并使用Base64URL
进行编码组成 JWT 结构的第一部分
载荷(Payload)
{
"exp": Math.floor(Date.now() / 1000 + 60 * 60), // 设置有效期为 1 个小时
"uid": "188888"
}
除了设置过期时间 exp 和用户的 uid ,还可以添加自定义私有字段,使用 Base64URL 进行编码组成 JWT 结构的第二部分
签名(Signature)
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
首先安装相关依赖
npm install --save passport-jwt @nestjs/jwt @nestjs/passport
接入 jwt 配置
- 分别创建
jwt.strategy.ts
、auth.service.ts
、auth.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
对象当中。
- 参数是经过解析后的
// 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 有效性的方法。 - 将相关方法封装起来,方便别的服务注入直接调用使用。
// 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 验证服务。
// 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 并且返回。
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
守卫文件
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>;
}
}
请求接口使用守卫
这里守卫主要有三种不同场景的配置:
- 单个 API 路由配置
-
- 使用
- 整个 Controller 下配置
- 整体应用的全局守卫配置
更多的 Nest.js 守卫的使用麻烦请移步到官方文档下面进行查阅,这里就不再累赘重复了。
结合着当前我们创建的 AuthGuard jwt 守卫,使用例子:
- 这里就最简单的使用方法,在相关需要使用 jwt 登录的 API 接口当中使用
@UseGuards
装饰器对 API 接口定义进行守卫注入。
@Put('password')
@UseGuards(AuthGuard)
setAdminPassword(
@Req() req,
@Body('password') password = '',
@Body('confirmPassword') confirmPassword = '',
) {
// TODO ...
}
经过这样子配置接口守卫之后,我们就能够实现请求自动进行用户登录的 token 的验证。
从 jwt 当中获取信息
在应用请求携带了相关的 token 信息之后,我们通过配置的请求守卫就会对该请求携带的 token 进行解析,如果通过之后就会在 req 对象里面挂载一个 user 新对象,这个 user 其实就是jwt.strategy.ts
jwt 校验策略当中我们在validate
当中 returrn 返回的数据。
- 因此我们可以在
jwt.strategy.ts
的validate
方法当中获取到通过sign
生成 jwt 时候设置的 payload 信息(例如 userId)再进行用户信息的查询获取,并且通过 return 返回设置到@Req.user
当中。 - 接着我们可以在请求方法内利用
@Req
装饰器获取到 req 对象进而获取到req.user
,从而获取到登录用户的基本信息。
三、同时存在并区分两套 JWT
这个场景可能并不是所有人都遇到,毕竟大一点的项目可能就会干脆拆分多个项目或者微服务,但是当(公司小,资源省)只有一个 node 服务资源的情况下,并且还要区分多端渠道登录的情况下就会有这样的一个诉求了。
还记得上一章节当中我们在创建 jwt 策略时候提及到的一个name
参数吗,就是这个参数能够让我们创建互不干扰的独立 jwt,供不同多端登录使用。
文件的拆分
拆分 B 端管理系统管理员登录和 C 端访客登录生成对应的 JWT 并且区分各自模块类别进行校验。
将 auth.module.ts
、jwt.strategy.ts
、auth.guard.ts
这几个文件分别拆分 manage 和 client 两份各自独立的文件(文件命名可以根据实际进行添加对应的前缀)。
eg.
配置和逻辑的区分调整
首先就是 jwt 校验策略的区分,主要就是传递第二个参数name
的不同:
- 区分 BC 两端分别传入两个不同的 name 'client-jwt' 和 'manage-jwt'
// 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;
}
}
// 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 的值
// 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 {}
// 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 校验逻辑还是基本上没区别;后续我们将结合着账号权限系统来改造账号验证守卫的处理。
// 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>;
}
}
// 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 端的守卫:
// 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);
}
}
// 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 当中如何对接口进行权限判断,敬请期待吧。