vue中使用双token机制
作者:小天呐
一、什么是双token机制?
双 Token 机制是一种增强身份验证安全性(主)和提升用户体验(次)的技术方案,常用于处理用户登录和会话管理,主要包含访问令牌(Access Token)和刷新令牌(Refresh Token)
二、基本概念
- 访问令牌(Access Token):短token
- 这是用于访问受保护资源的短期令牌,通常有效期较短,比如 15 分钟到 1 小时不等。
- 它包含了用户的身份信息和权限声明,服务器通过验证该令牌来确认用户是否有权限访问特定资源。
- 由于有效期短,即使被窃取,攻击者利用它进行恶意操作的时间窗口也有限。
- 刷新令牌(Refresh Token):长token
- 刷新令牌的有效期较长,可能是几天甚至几周。
- 其作用是在访问令牌过期后,用于获取新的访问令牌,而无需用户重新登录。
- 刷新令牌通常存储在更安全的位置,如 HTTP - Only Cookie 中,以降低被窃取的风险。
三、工作流程
- 用户登录:用户在客户端输入用户名和密码等凭据进行登录。服务器验证这些凭据,若验证通过,会生成一个访问令牌和一个刷新令牌,并将它们返回给客户端。
- 访问资源:客户端在后续请求中携带访问令牌,服务器验证该令牌的有效性。如果令牌有效,服务器处理请求并返回响应。
- 访问令牌过期:当访问令牌过期后,客户端在下次请求时会收到服务器返回的 “令牌过期” 错误。
- 刷新令牌:客户端使用刷新令牌向服务器发起刷新请求。服务器验证刷新令牌的有效性,如果有效,会生成一个新的访问令牌,并返回给客户端。
- 新的访问资源:客户端使用新的访问令牌继续访问受保护资源。
- 刷新令牌过期:当刷新令牌也过期时,用户需要重新登录以获取新的访问令牌和刷新令牌。
因为 access_token 如果有效期太短,用户就需要频繁地进行身份验证,用户体验差。设置得太长呢,一旦 access token 被获取之后被冒用的可能性大。所以使用 refresh token 就可以把access token 的有效期缩短,在提高安全性的同时还保证了用户体验。
四、具体实现
这里前端采用 vue3 + axios,后端采用 nest.js 实现
1. 创建后端登录接口
要求:登录成功返回两个 token,一个用于刷新 token(refresh token
),一个用于访问 token(access token
)。
1.1 创建 access、refresh token 模块
Config 模块用来读取
.env
文件配置
// src/module/jwt-access.module.ts import { Module } from '@nestjs/common' import { JwtModule, JwtService } from '@nestjs/jwt' import { ConfigModule, ConfigService } from '@nestjs/config' import { JWT_ACCESS_EXPIRES_IN, JWT_ACCESS_SECRET } from '../common/constant/env' @Module({ imports: [ JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: async (configService: ConfigService) => ({ secret: configService.get<string>(JWT_ACCESS_SECRET), signOptions: { expiresIn: configService.get<string>(JWT_ACCESS_EXPIRES_IN) } }) }) ], providers: [ { // 创建一个别名,方便在 auth.service.ts 中注入 provide: 'JWT_ACCESS', useExisting: JwtService } ], exports: [JwtModule, 'JWT_ACCESS'] }) export class JwtAccessModule {}
// src/module/jwt-refresh.module.ts import { Module } from '@nestjs/common' import { JwtModule, JwtService } from '@nestjs/jwt' import { ConfigModule, ConfigService } from '@nestjs/config' import { JWT_REFRESH_EXPIRES_IN, JWT_REFRESH_SECRET } from '../common/constant/env' @Module({ imports: [ JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: async (configService: ConfigService) => ({ secret: configService.get<string>(JWT_REFRESH_SECRET), signOptions: { expiresIn: configService.get<string>(JWT_REFRESH_EXPIRES_IN) } }) }) ], providers: [ { provide: 'JWT_REFRESH', useExisting: JwtService } ], exports: [JwtModule, 'JWT_REFRESH'] }) export class JwtRefreshModule {}
# .env DB_TYPE=mysql DB_DATABASE=code-blocks-DB JWT_ACCESS_SECRET=code-blocks-server-access-secret JWT_ACCESS_EXPIRES_IN=30m JWT_REFRESH_SECRET=code-blocks-server-refresh-secret JWT_REFRESH_EXPIRES_IN=7d
1.2 在 auth.module.ts 中注入两个模块
// src/module/auth.module.ts import { Module } from '@nestjs/common' import { AuthController } from '../controllers/auth.controller' import { AuthService } from '../services/auth.service' import { TypeOrmModule } from '@nestjs/typeorm' import { User } from '../entities/user.entity' import { JwtAccessModule } from './jwt-access.module' import { JwtRefreshModule } from './jwt-refresh.module' @Module({ imports: [ TypeOrmModule.forFeature([User]), JwtAccessModule, JwtRefreshModule ], controllers: [AuthController], providers: [AuthService] }) export class AuthModule {}
1.3 创建两个 jwt 的校验策略
这一步是结合后续创建的管道,来对接口的请求进行校验,例如:@UseGuards(JwtRefreshGuard)
在 PassportStrategy
第二个参数传入 name
,为了后续 guard
中进行区分
jwtFromRequest 来判断 jwt 从什么地方获取:
- 从请求头中
Authorization
获取 :jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
- 自定义:从请求头中获取自定义的 Refresh-Token 字段
// src/strategy/jwt-access.strategy.ts import { ExtractJwt, Strategy } from 'passport-jwt' import { PassportStrategy } from '@nestjs/passport' import { Injectable } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { JWT_ACCESS_SECRET } from '../common/constant/env' import { InjectRepository } from '@nestjs/typeorm' import { User } from '../entities/user.entity' import { Repository } from 'typeorm' @Injectable() export class JwtAccessStrategy extends PassportStrategy( Strategy, 'jwt-access' ) { constructor( protected configService: ConfigService, @InjectRepository(User) private usersRepository: Repository<User> ) { super({ // 从请求头中获取token jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: configService.get<string>(JWT_ACCESS_SECRET) }) } // 对token进行校验,会在req.user上添加信息 async validate(payload: { userId: string; phone: string }) { const user_info = await this.usersRepository.findOne({ where: { id: payload.userId }, select: ['id', 'phone', 'is_status'] }) if (!user_info) return false if (!user_info.is_status) return false if (user_info.phone !== payload.phone && user_info.id !== payload.userId) return false return { userId: payload.userId, phone: payload.phone } } }
// src/strategy/jwt-refresh.strategy.ts import { Strategy } from 'passport-jwt' import { PassportStrategy } from '@nestjs/passport' import { Injectable, UnauthorizedException } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { JWT_REFRESH_SECRET } from '../common/constant/env' import { InjectRepository } from '@nestjs/typeorm' import { User } from '../entities/user.entity' import { Repository } from 'typeorm' @Injectable() export class JwtRefreshStrategy extends PassportStrategy( Strategy, 'jwt-refresh' ) { constructor( protected configService: ConfigService, @InjectRepository(User) private usersRepository: Repository<User> ) { super({ // 从请求头中获取自定义的 Refresh-Token 字段 jwtFromRequest: (req: Request) => { const refreshToken = req.headers['refresh-token'] if (!refreshToken) { throw new UnauthorizedException('Refresh token is required') } return refreshToken }, ignoreExpiration: false, secretOrKey: configService.get<string>(JWT_REFRESH_SECRET) }) } // 对token进行校验,会在req.user上添加信息 async validate(payload: { userId: string; phone: string }) { const user_info = await this.usersRepository.findOne({ where: { id: payload.userId }, select: ['id', 'phone', 'is_status'] }) if (!user_info) return false if (!user_info.is_status) return false if (user_info.id !== payload.userId) return false return { userId: payload.userId, phone: user_info.phone } } }
1.4 全局注册策略(Strategy)
nest 通过依赖注入的方式来实现模块之间的使用,也可以局部注册。只有注册后,全局的守卫才会生效。
// src/app.module.ts import { Global, Logger, Module } from '@nestjs/common' // 不同模块 import { AuthModule } from './modules/auth.module' import { EditModule } from './modules/edit.module' import { UserModule } from './modules/user.module' import { TypeOrmConfigModule } from './config/typeorm.module' import { LogConfigModule } from './config/log.module' import { ENV_Config_Module } from './config/config.module' import { JwtAccessStrategy } from './strategy/jwt-access.strategy' import { TypeOrmModule } from '@nestjs/typeorm' import { User } from './entities/user.entity' import { JwtRefreshStrategy } from './strategy/jwt-refresh.strategy' @Global() @Module({ imports: [ ENV_Config_Module, TypeOrmConfigModule, TypeOrmModule.forFeature([User]), LogConfigModule, AuthModule, EditModule, UserModule ], controllers: [], // 全局提供logger,从@nestjs/common进行导入。因为在main.ts中重构官方的logger实例 // JwtAccessStrategy、JwtRefreshStrategy 策略使其在全局都能使用 providers: [Logger, JwtAccessStrategy, JwtRefreshStrategy], exports: [Logger] }) export class AppModule {}
1.5 创建两个 jwt 的守卫
实现接口注解,@UseGuards(JwtAccessGuard)
和 @UseGuards(JwtRefreshGuard)
来实现统一校验
例如:
// xxx.controller.ts // 全局接口 @Controller('edit') @UseGuards(JwtAccessGuard) export class EditController {} // 单独接口 @Get('refresh-token') @UseGuards(JwtRefreshGuard) async refreshToken(@Headers('Refresh-Token') refreshToken: string) {}
实现:
// src/guard/jwt-access.guard.ts import { AuthGuard } from '@nestjs/passport' /** * AuthGuard 默认为 jwt,也可以在 strategy/jwt.strategy.ts 中修改为其他策略 * JwtAccessStrategy 继承的 PassportStrategy,在 PassportStrategy 第二个参数就是 name 值 * */ export class JwtAccessGuard extends AuthGuard('jwt-access') { constructor() { super() } }
// src/guard/jwt-refresh.guard.ts import { AuthGuard } from '@nestjs/passport' export class JwtRefreshGuard extends AuthGuard('jwt-refresh') { constructor() { super() } }
1.6 实现刷新 token 接口
刷新 token 接口有两种思路:
- 接口返回两个 token,这样后续就可以保证这个 长 token(refresh token)永远不会过期。
- 接口只返回短 token,长 token 会过期,例如 7 天后过期用户也会重新登录。(这里采用这种方式)
token 存储方式也有几种:自行选择
- cookie(refresh token) + localStorage(access token)
- localStorage(refresh token + access token)
// src/controller/auth.controller.ts /** * 刷新token接口 * @headers headers['Refresh-Token'] 刷新token */ @Get('refresh-token') @UseGuards(JwtRefreshGuard) async refreshToken(@Headers('Refresh-Token') refreshToken: string) { const data = await this.authService.refreshToken(refreshToken); return { code: 200, message: '刷新token成功', data, }; }
// src/service/auth.service.ts import { HttpException, HttpStatus, Inject, Injectable, NotFoundException } from '@nestjs/common' import { Repository } from 'typeorm' import { InjectRepository } from '@nestjs/typeorm' import { RegisterDto } from '../dto/user/register.dto' import { JwtService } from '@nestjs/jwt' @Injectable() export class AuthService { constructor( @InjectRepository(User) private usersRepository: Repository<User>, // 使用 @Inject 手动注入依赖,通过在 1.1 中注入的内容实现 @Inject('JWT_ACCESS') private readonly jwtAccess: JwtService, @Inject('JWT_REFRESH') private readonly jwtRefresh: JwtService ) {} /** * 刷新token */ async refreshToken(refreshToken: string): Promise<{ accessToken: string }> { const payload = this.jwtRefresh.decode(refreshToken) const user = await this.usersRepository.findOne({ where: { id: payload.userId }, select: ['id', 'phone'] }) if (!user) { throw new NotFoundException('用户不存在') } const accessToken = await this.jwtAccess.signAsync({ userId: user.id, phone: user.phone }) return { accessToken } } }
2. 前端登录
2.1 新增刷新 token 接口
export const reqRefreshToken = () => request<any, RefreshTokenResponse>({ url: API.refreshToken, method: 'get', headers: { 'Refresh-Token': getRefreshToken() } })
2.2 配置请求响应拦截器
需要注意的点:
- 在携带 access token 的接口,返回 401 时,就需要发送 reqRefreshToken 来刷新 token
- 在页面多个并发请求时,需要创建一个请求队列,当 token 刷新后重新发送请求
import axios, { type AxiosRequestConfig } from 'axios' import router from '@/router/index' import { useUserStore } from '@/stores/user' import { ElMessage } from 'element-plus' import { baseUrl } from '@/common/baseUrl' import { API as AuthAPI, reqRefreshToken } from './auth' const user = useUserStore() const request = axios.create({ baseURL: baseUrl, timeout: 300000 }) request.interceptors.request.use( (config) => { const user = useUserStore() if (user.token) config.headers['Authorization'] = 'Bearer ' + user.token return config }, (error) => { return Promise.reject(error) } ) // 已经处理过的错误码 const hasErrorCode = [401, 403] // 在刷新token时,如果页面上有多个请求,当token过期后,那这几个请求都会触发 reqRefreshToken 来刷新token /** * 1. 需要定义一个变量来标记当前是否刷新中,避免重复刷新token * 2. 创建一个请求队列,当刷新token成功后,需要把队列中的请求重新发送 */ let isRefreshing = false interface PendingTask { config: AxiosRequestConfig resolve: (value?: any) => void } const requestQueue: PendingTask[] = [] request.interceptors.response.use( async (response) => { if ( response.data?.code === 401 && !response.config.url?.includes(AuthAPI.refreshToken) ) { if (!isRefreshing) { // 第一个触发 401 的请求,刷新 token 并重新发送队列中的请求 isRefreshing = true const res = await reqRefreshToken() isRefreshing = false if (res.code === 200) { const accessToken = res.data.accessToken // 更新accessToken user.refresh(accessToken) // 重新请求 requestQueue.forEach(({ config, resolve }) => { config.headers!['Authorization'] = 'Bearer ' + accessToken resolve(request(config)) }) requestQueue.length = 0 /** * 如果同时多个请求,在其他几个请求中,有一个先返回响应,先响应的回执行 requestQueue.forEach, * 此时这个 requestQueue 没有当前请求,则需要返回当前这个请求,重新去执行 * (返回一个Promise会直接去执行) */ return request(response.config) } else { // refreshToken过期 user.clearInfo() router.replace('/login') ElMessage.error('登录已过期,请重新登录') } } else { // 当前请求不是第一个触发 401 的请求,则将当前为401(token过期的请求)添加到请求队列中 return new Promise((resolve) => { requestQueue.push({ config: response.config, resolve }) }) } return } if (response.data?.code === 403) { user.clearInfo() router.replace('/login') ElMessage.error('无权限') return } if (response.data?.code === 200) { response.data['status'] = true } if ( !response.data['status'] && !hasErrorCode.includes(response.data?.code) ) { ElMessage.error(response.data.message) } return response.data }, (error) => { return Promise.reject(error) } ) export default request
到此这篇关于vue中使用双token机制的文章就介绍到这了,更多相关vue 双token机制内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!