在上个月我收到了一份意外的礼物
在拿到书的第一天,我在形策课上便读到了170多页,至今已经全部阅读完成,让我对前后端交互,AOP、IOC思想,模块化等的理解又进了一步!!!特别感谢元兮大大,能看中我这个爱玩技术的小屁孩!
在阅读了全文后,我仔细复盘了我之前写的双token无感刷新方案与大文件切片上传方案后,我进行了详细的重构。而本文将带来使用nestjs完成双token无感刷新的后端部分!
什么是jwt,什么是token,《Nestjs全站开发解析》第171页有详细解释
双token的无感刷新的业务逻辑如下:
现在我们来完成代码部分的实现~
双token无感刷新本质上也是一种权限管理,因此在实现上需要实现如下功能:
- 权限守卫
- 登录接口无需校验权限(实现校验放行标识)
- 刷新短token接口
我们一步步来实现,先实现创建权限模块,使用如下命令创建模块
// 创建项目
nest new project-name
// 创建auth模块
nest g resource auth
JWT配置
安装jwt第三方包
npm install --save @nestjs/jwt
/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';
// jwt
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { AuthGuard } from './auth.guard';
import { APP_GUARD } from '@nestjs/core';
@Module({
imports: [UserModule,
JwtModule.register({
// 全局配置,可以直接注入并使用@Inject(JwtService)来获取JwtService实例
global: true,
// 签名密钥(secret)
secret: jwtConstants.secret,
// 签发出去的jwt的过期时间,时间单位可以是秒(s)、分钟(m)、小时(h)、天(d)等
signOptions: { expiresIn: '60s' },
}),],
controllers: [AuthController],
providers: [AuthService,
{
// 告诉Nest你要注册的是一个全局守卫
provide: APP_GUARD,
// 指定实际要使用的守卫类是AuthGuard
useClass: AuthGuard,
},
],
})
export class AuthModule { }
/constants.ts
// jwt密钥
export const jwtConstants = {
// 随便写
secret: '&kl-*/***-7s%%sa#j@@|d/*-*/*-kl/*-*|/ajs@%@kl,,dd+*/..fsdf/5@6&sa1|&fh5@|6/*+/*fd439%#0[]]asjd+6',
};
权限守卫
/auth.guard.ts
import {
CanActivate, // 用于定义守卫
ExecutionContext,// 执行上下文
Injectable, // 未授权异常
UnauthorizedException, // 标记服务为可注入
} from '@nestjs/common';
// 处理JWT的生成和验证
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from './auth.decorators'
import { Reflector } from '@nestjs/core';
/**
* 定义 AuthGuard 类,实现 CanActivate 接口以充当 Nest.js 应用程序中的认证守卫。
* 负责验证传入请求中的 JWT 令牌,并处理未授权情况。
*/
@Injectable()
export class AuthGuard implements CanActivate {
/**
* 构造函数初始化 JWT 服务和反射器,这两个都是认证过程中必需的依赖。
*
* @param jwtService 用于 JWT 令牌的生成与验证服务。
* @param reflector 用于读取控制器和方法上的自定义装饰器元数据。
*/
constructor(private jwtService: JwtService, private reflector: Reflector) { }
/**
* canActivate 方法决定请求是否可以继续执行。
*
* @param context 提供执行上下文,从中可以获取到请求和响应对象。
* @returns 返回一个 Promise,解析为布尔值,指示是否允许请求继续。
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
// 检查控制器或方法是否标记为公开,如果是,则无需认证直接放行。
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
// 携带了@Public()装饰器,说明可以放行
if (isPublic) {
return true;
}
// 从 HTTP 请求中提取 JWT 令牌。
const request: Request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
// 如果没有找到有效的 JWT 令牌,则说明没有登录
if (!token) {
throw new UnauthorizedException('用户未登录');
}
try {
// 验证 JWT 令牌,并将有效载荷存储在请求对象中,便于后续使用。
const payload = await this.jwtService.verifyAsync(token, {
secret: jwtConstants.secret,
});
request['user'] = payload;
} catch {
// 验证失败时,同样抛出未授权异常。
throw new UnauthorizedException('token 失效,请重新登录');
}
// 验证通过,允许请求继续。
return true;
}
/**
* 从请求的 Authorization 头中提取 JWT 令牌。
*
* @param request Express 请求对象。
* @returns 返回提取到的 JWT 令牌,如果不存在或格式错误则返回 undefined。
*/
private extractTokenFromHeader(request: Request): string | undefined {
// 分割 Authorization 头,预期格式为 'Bearer {token}'。
const [type, token] = request.headers.authorization?.split(' ') ?? [];
// 确保类型为 'Bearer' 且存在 token 时才返回 token。
return type === 'Bearer' ? token : undefined;
}
}
校验放行标识
需要实现一个装饰器来作为放行标识
什么是装饰器,有哪些内置的装饰器《Nestjs全站开发解析》第51页有详细解释
src\auth\auth.decorators.ts 没有这个文件自己创建一个~
// 自定义装饰器
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
// 可以使用@Public()装饰器装饰任何方法,使当前被装饰的接口无需进行任何的权限验证
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
接口实现
连接数据库
安装依赖
npm install --save @nestjs/typeorm typeorm mysql2
根据模块化思想,需要查询用户信息是否存在于数据库中,因此需要新开辟一个模块
nest g resource user
配置数据库
没安装mysql的要安装mysql数据库哟
src\app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';// 操作数据库的包
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
import { FileModule } from './file/file.module';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
TypeOrmModule.forRoot({
type: "mysql", // 数据库类型
username: "root", //你自己的mysql账号
password: "123456", // 你自己的密码
host: "localhost", // host
port: 3306,
database: "financial_system", // 库名
synchronize: true, // synchronize字段代表是否自动将实体类同步到数据库
retryDelay: 500, // 重试连接数据库间隔
retryAttempts: 0,// 重试连接数据库的次数
autoLoadEntities: true, // 如果为true,将自动加载实体 forFeature()方法注册的每个实体都将自动添加到配置对象的实体数组中
logging: false, // 是否打印日志
}),
UserModule, FileModule, AuthModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule { }
配置user模块中的service类
src\user\user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/User.entity';
@Injectable()
export class UserService {
constructor(@InjectRepository(User) private readonly userRepository: Repository<User>) { }
// 根据用户名称查找用户
async getUserByUsername(username: string): Promise<User | null> {
const res = await this.userRepository.findOne({ where: { username } });
return res
}
// 根据id查询一个用户
async getUserById(id: number): Promise<User | null> {
const res = await this.userRepository.findOne({ where: { id } }) // 根据id查询单个
return res
}
}
src\user\entities\User.entity.ts 对应的user类
import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm";
@Index("uuid", ["uuid"], { unique: true }) //设置唯一索引
@Entity("user", { schema: "financial-system" })
export class User {
@PrimaryGeneratedColumn({ type: "int", name: "id", comment: "主键id" })
id: number;
@Column("varchar", {
name: "uuid",
unique: true,
comment: "uuid主键",
length: 150,
})
uuid: string;
@Column("varchar", {
name: "username",
comment: "姓名",
length: 100,
})
username: string;
@Column("varchar", { name: "password", comment: "密码", length: 255 })
password: string;
@Column("varchar", {
name: "email",
nullable: true,
comment: "邮箱",
length: 100,
})
email: string | null;
@Column("varchar", {
name: "mobile",
nullable: true,
comment: "手机号码",
length: 11,
})
mobile: string | null;
@Column("tinyint", {
name: "gender",
nullable: true,
comment: "性别",
default: () => "'0'",
})
gender: number | null;
@Column("timestamp", {
name: "create_at",
comment: "创建时间",
default: () => "CURRENT_TIMESTAMP",
})
createAt: Date;
@Column("timestamp", {
name: "update_at",
comment: "更新时间",
default: () => "CURRENT_TIMESTAMP",
})
updateAt: Date;
}
上述user模块中的用户类中有两个方法,我们的登录注册,短token刷新接口都将依赖这上个方法
回到我们的auth模块中
auth.service层 业务逻辑层
该service层会调用user模块中的service类中的两个方法
src\auth\auth.controller.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UserService } from '../user/user.service';
// jwt生成器
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(private readonly userService: UserService, private jwtService: JwtService) { }
// 判断用户是否存在后发放jwt版本的token
async signIn(username: string, pw: string): Promise<any> {
const user = await this.userService.getUserByUsername(username);
if (!user) throw new UnauthorizedException("用户不存在")
if (user?.password != pw) throw new UnauthorizedException("密码错误")
const access_token: string = await this.jwtService.signAsync({ sub: user.id, username: user.username }, { expiresIn: '10s' });// 短token
const refresh_token: string = await this.jwtService.signAsync({ sub: user.id, }, { expiresIn: '30h' });// 长token
return {
access_token,
refresh_token
};
}
// 刷新双token的方法
async refresh(refreshToken: string) {
const token = this.extractTokenFromHeader(refreshToken)
// 从长token中获取当前用户id
const data = this.jwtService.verify(token);
// 根据id查询一个用户
const user = await this.userService.getUserById(data.userId);
// 全新的长短token
const access_token: string = await this.jwtService.signAsync({ sub: user.id, username: user.username }, { expiresIn: '10s' });
const refresh_token: string = await this.jwtService.signAsync({ sub: user.id, }, { expiresIn: '10m' });
return {
access_token,
refresh_token
}
}
/**
* 从请求的 Authorization 头中提取 JWT 令牌。
*
* @param request Express 请求对象。
* @returns 返回提取到的 JWT 令牌,如果不存在或格式错误则返回 undefined。
*/
private extractTokenFromHeader(request: string): string | undefined {
// 分割 Authorization 头,预期格式为 'Bearer {token}'。
const [type, token] = request?.split(' ') ?? [];
// 确保类型为 'Bearer' 且存在 token 时才返回 token。
return type === 'Bearer' ? token : undefined;
}
}
auth.Controller层 接口层
调用service类中封装好了的业务逻辑
src\auth\auth.controller.ts
import {
Body,
Controller,
Get,
Headers,
HttpCode,
HttpStatus,
Post,
UnauthorizedException,
UseGuards
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthGuard } from './auth.guard';
import { Public } from './auth.decorators';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
@Controller('auth')
@ApiTags('守卫接口(\\auth)')
export class AuthController {
constructor(private readonly authService: AuthService) { }
@ApiOperation({ summary: '登录', description: '登录接口' })
@HttpCode(HttpStatus.OK)
@Post('login')
@Public()// 公共方法,无需授权
signIn(@Body() signInDto: Record<string, any>) {
return this.authService.signIn(signInDto.username, signInDto.password);
}
@ApiOperation({ summary: '刷新token', description: '刷新token接口' })
@Get('refresh')
async refresh(@Headers('Authorization') refreshToken: string,) {
try {
return await new Promise((resolve) => {
setTimeout(() => {
resolve(this.authService.refresh(refreshToken))
}, 5000)
})
} catch (e) {
throw new UnauthorizedException('刷新token 已失效,请重新登录');
}
}
@ApiOperation({ summary: 'token授权成功接口', description: 'token授权成功测试接口' })
@UseGuards(AuthGuard)
@Get('profile')
getProfile() {
console.log("验证通过")
return {
msg: "验证通过"
};
}
}
至此全部实现!若想CR我的代码,请点击如下链接:
FengBuPi/Dual-token-silent-refresh: 双axios双token无感刷新技术方案demo (github.com)
特别感谢元兮大大的大力支持