一、概述
双 Token 认证是一种安全的身份验证机制,通过同时使用 Access Token 和 Refresh Token 来平衡安全性和用户体验。
1.1 双 Token 设计原理
1.2 安全优势
短期 Access Token:即使泄露,有效期短,风险可控
长期 Refresh Token:仅用于刷新操作,不直接访问业务接口
Token 轮换:每次刷新生成新的双 Token,降低被盗用风险
二、项目结构
FE-WEB/
├── apps/web/src/apis/ # 前端 API 拦截器
│ ├── index.ts # 主拦截器配置
│ ├── auth/index.ts # 认证相关 API
│ └── user/index.ts # 用户相关 API
├── package/common/user/index.ts # 类型定义
└── BE-SERVER/
├── apps/server/src/
│ ├── auth/ # Auth 模块
│ ├── user/ # User 模块
│ └── app.module.ts # 应用根模块
└── libs/shared/src/
├── auth/auth.guard.ts # 认证守卫
└── shared.module.ts # 共享模块配置三、后端实现
3.1 JWT 全局配置
文件: BE-SERVER/libs/shared/src/shared.module.ts
// 全局 JWT 配置
import { JwtModule } from '@nestjs/jwt'
import { ConfigModule, ConfigService } from '@nestjs/config'
/** 全局 JWT 配置: 需要异步加载环境变量注册配置 */
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
global: true, // 全局模块
secret: configService.get('SECRET_KEY'), // 密钥(环境变量)
signOptions: { expiresIn: '15m' }, // 默认过期时间
}),
})配置说明:
global: true:JWT 模块全局生效secret:从环境变量读取密钥,避免硬编码signOptions.expiresIn:默认过期时间 15 分钟
3.2 Token 类型定义
文件: package/common/user/index.ts
/** 令牌有效载荷接口 */
export type TokenPayload = Pick<User, "name" | "email"> & {
userId: User["id"];
};
/** 带类型的令牌载荷接口 */
export type TypedTokenPayload = TokenPayload & {
tokenType: "refresh" | "access";
};
/** 令牌接口 */
export type Token = {
accessToken: string;
refreshToken: string;
};3.3 AuthService - Token 生成与验证
文件: BE-SERVER/apps/server/src/auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(private jwtService: JwtService) {}
/**
* 生成双 Token
* @param payload 有效载荷(userId, name, email)
* @returns Token 对象
*/
generateToken(payload: TokenPayload): Token {
return {
// Access Token:使用默认配置(15分钟)
accessToken: this.jwtService.sign<TypedTokenPayload>({
...payload,
tokenType: 'access',
}),
// Refresh Token:自定义过期时间(7天)
refreshToken: this.jwtService.sign<TypedTokenPayload>(
{ ...payload, tokenType: 'refresh' },
{ expiresIn: '7d' },
),
};
}
/**
* 验证 Token
* @param token JWT 字符串
* @returns 解码后的载荷或 null
*/
verifyToken(token: string): TypedTokenPayload | null {
try {
// 该方法是同步执行并且抛出异常,需要捕 try catch 处理
return this.jwtService.verify<TypedTokenPayload>(token);
} catch {
return null; // 验证失败返回 null,避免异常抛出
}
}
}3.4 AuthGuard - 认证守卫
文件: BE-SERVER/libs/shared/src/auth/auth.guard.ts
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const headers = request.headers;
// 1. 检查 Authorization 头是否存在
if (!headers.authorization) {
throw new UnauthorizedException('未授权');
}
// 2. 提取 Token(格式: Bearer xxx)
const token = headers.authorization.split(' ')[1];
try {
// 3. 验证 Token 签名和过期时间
const payload = this.jwtService.verify<TypedTokenPayload>(token);
// 4. 验证 Token 类型必须为 access
if (payload.tokenType !== 'access') {
throw new UnauthorizedException('token无效');
}
// 5. 将用户信息挂载到 request 对象
request.user = payload;
return true;
} catch {
throw new UnauthorizedException('token认证失败');
}
}
}使用方式: 在需要认证的控制器上使用 @UseGuards(AuthGuard) 装饰器
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { WordBookService } from './word-book.service';
import { AuthGuard } from '@libs/shared';
@Controller('word-book')
export class WordBookController {
constructor(private readonly wordBookService: WordBookService) {}
@UseGuards(AuthGuard)
@Get()
findAll(@Query() query: WordQuery) {
return this.wordBookService.findAll(query);
}
}
3.5 UserService - 登录与刷新逻辑
文件: BE-SERVER/apps/server/src/user/user.service.ts
登录流程
async login(userLogin: UserLogin) {
// ...... 登录逻辑
// 生成双 Token
const token = this.authService.generateToken({
userId: updatedUser.id,
name: updatedUser.name,
email: updatedUser.email || '',
});
return this.response.success({ ...updatedUser, token });
}刷新 Token 流程
async refreshToken(refreshToken: Omit<Token, 'accessToken'>) {
try {
// 1. 验证 Refresh Token
const payload = this.authService.verifyToken(refreshToken.refreshToken);
if (!payload || payload.tokenType !== 'refresh') {
return this.response.error(null, '无效的刷新token');
}
// 2. 验证用户是否存在(防止用户已删除)
const user = await this.prisma.user.findUnique({
where: { id: payload.userId },
select: this.select,
});
if (!user) {
return this.response.error(null, '无效的刷新token');
}
// 3. 生成新的双 Token(Token 轮换)
const token = this.authService.generateToken({
userId: user.id,
name: user.name,
email: user.email || '',
});
return this.response.success(token);
} catch (error) {
return this.response.error(null, '刷新token失败');
}
}3.6 模块依赖关系
AppModule
├── SharedModule (全局)
│ ├── JwtModule (全局)
│ ├── PrismaModule
│ └── ResponseModule
├── AuthModule
│ └── AuthService (依赖 JwtService)
└── UserModule
└── UserService (依赖 AuthService)四、前端实现
4.1 API 拦截器配置
文件: apps/web/src/apis/index.ts
请求拦截器
serverApi.int
erceptors.request.use(config => {
const userStore = useUserStore();
// 请求前自动添加 Authorization 头
if (userStore.getAccessToken()) {
config.headers['Authorization'] = `Bearer ${userStore.getAccessToken()}`;
}
return config;
});响应拦截器(核心逻辑)
/** 刷新状态标志(互斥锁) */
let isRefreshing = false;
/** 等待刷新的请求队列 */
let requestsQueue: ((newAccessToken: string) => void)[] = [];
serverApi.interceptors.response.use(
res => res.data,
async err => {
// 非 401 错误直接拒绝
if (err.response?.status !== 401) {
return Promise.reject(err);
}
const userStore = useUserStore();
const accessToken = userStore.getAccessToken();
const refreshToken = userStore.getRefreshToken();
// Token 为空,直接退出登录
if (!accessToken || !refreshToken) {
userStore.logout();
router.replace('/');
return Promise.reject(err);
}
// 并发控制:如果正在刷新,加入队列等待
if (isRefreshing) {
return new Promise(resolve => {
requestsQueue.push((newAccessToken: string) => {
err.config.headers['Authorization'] = `Bearer ${newAccessToken}`;
resolve(serverApi(err.config)); // 刷新成功后重发请求
});
});
}
// 开始刷新流程
isRefreshing = true;
try {
// 调用刷新 Token API
const newToken = await refreshTokenApi({ refreshToken });
if (newToken.success) {
// 更新本地 Token
userStore.updateToken(newToken.data);
// 重放队列中的所有请求
requestsQueue.forEach(resolve => resolve(newToken.data.accessToken));
// 重发当前触发刷新的请求
return serverApi(err.config);
} else {
// 刷新失败,退出登录
userStore.logout();
router.replace('/');
return Promise.reject(err);
}
} catch (error) {
return Promise.reject(error);
} finally {
// 清理状态
requestsQueue = [];
isRefreshing = false;
}
}
);4.2 刷新 Token API
文件: apps/web/src/apis/auth/index.ts
刷新 Token API: 是基于另一个axios实例创建的,专门用于刷新token。
设计优势:
与主 API 实例分离,避免主 API 实例被刷新 Token 操作影响
刷新 Token 操作独立于其他 API 请求,确保刷新 Token 操作的独立性,避免与其他API耦合
export const refreshTokenApi = (data: Omit<Token, 'accessToken'>) =>
authApi.post('/user/refresh-token', data) as Promise<Response<Token>>;4.3 Pinia 状态管理
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const accessToken = ref('');
const refreshToken = ref('');
const getAccessToken = () => accessToken.value;
const getRefreshToken = () => refreshToken.value;
const updateToken = (token: Token) => {
accessToken.value = token.accessToken;
refreshToken.value = token.refreshToken;
};
const logout = () => {
accessToken.value = '';
refreshToken.value = '';
};
return {
accessToken,
refreshToken,
getAccessToken,
getRefreshToken,
updateToken,
logout,
};
});五、完整工作流程图
登录流程
Token过期刷新流程
并发请求处理流程
六、安全考虑
6.1 Token 轮换策略
每次刷新 Token 时生成全新的双 Token:
// 每次刷新都生成新的 Token
const token = this.authService.generateToken({
userId: user.id,
name: user.name,
email: user.email || '',
});优点:
即使 Refresh Token 泄露,有效期有限
被盗用后,原用户刷新会使被盗用的 Token 失效
6.2 错误处理
遇到问题怎么处理:
Access Token 过期了:系统会自动刷新获取新的 Token,用户完全感觉不到
Refresh Token 也过期了:没办法,只能让用户重新登录
Token 被改了:签名验证会失败,直接返回 401 拒绝访问
用户账号被删了:刷新时会检查用户是否存在,不存在就提示登录
七、实际效果
token未过期时,并发的请求处理

token过期时,并发的请求处理
由于access token的过期时间设置过短,测试没有把握好时机,导致会多次请求刷新token

八、总结
双 Token 认证机制通过以下方式实现安全与体验的平衡:
短期 Access Token:降低泄露风险
长期 Refresh Token:减少用户登录频率
并发控制:避免重复刷新请求
Token 轮换:每次刷新生成新 Token
该实现覆盖了完整的认证流程:
登录生成 Token
请求携带 Token
过期自动刷新
失败强制登出
双Token认证(Dual Token Authentication)
本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。



评论交流
欢迎留下你的想法