vue中使用雙token機(jī)制
一、什么是雙token機(jī)制?
雙 Token 機(jī)制是一種增強(qiáng)身份驗(yàn)證安全性(主)和提升用戶體驗(yàn)(次)的技術(shù)方案,常用于處理用戶登錄和會話管理,主要包含訪問令牌(Access Token)和刷新令牌(Refresh Token)
二、基本概念
- 訪問令牌(Access Token):短token
- 這是用于訪問受保護(hù)資源的短期令牌,通常有效期較短,比如 15 分鐘到 1 小時不等。
- 它包含了用戶的身份信息和權(quán)限聲明,服務(wù)器通過驗(yàn)證該令牌來確認(rèn)用戶是否有權(quán)限訪問特定資源。
- 由于有效期短,即使被竊取,攻擊者利用它進(jìn)行惡意操作的時間窗口也有限。
- 刷新令牌(Refresh Token):長token
- 刷新令牌的有效期較長,可能是幾天甚至幾周。
- 其作用是在訪問令牌過期后,用于獲取新的訪問令牌,而無需用戶重新登錄。
- 刷新令牌通常存儲在更安全的位置,如 HTTP - Only Cookie 中,以降低被竊取的風(fēng)險。
三、工作流程
- 用戶登錄:用戶在客戶端輸入用戶名和密碼等憑據(jù)進(jìn)行登錄。服務(wù)器驗(yàn)證這些憑據(jù),若驗(yàn)證通過,會生成一個訪問令牌和一個刷新令牌,并將它們返回給客戶端。
- 訪問資源:客戶端在后續(xù)請求中攜帶訪問令牌,服務(wù)器驗(yàn)證該令牌的有效性。如果令牌有效,服務(wù)器處理請求并返回響應(yīng)。
- 訪問令牌過期:當(dāng)訪問令牌過期后,客戶端在下次請求時會收到服務(wù)器返回的 “令牌過期” 錯誤。
- 刷新令牌:客戶端使用刷新令牌向服務(wù)器發(fā)起刷新請求。服務(wù)器驗(yàn)證刷新令牌的有效性,如果有效,會生成一個新的訪問令牌,并返回給客戶端。
- 新的訪問資源:客戶端使用新的訪問令牌繼續(xù)訪問受保護(hù)資源。
- 刷新令牌過期:當(dāng)刷新令牌也過期時,用戶需要重新登錄以獲取新的訪問令牌和刷新令牌。
因?yàn)?access_token 如果有效期太短,用戶就需要頻繁地進(jìn)行身份驗(yàn)證,用戶體驗(yàn)差。設(shè)置得太長呢,一旦 access token 被獲取之后被冒用的可能性大。所以使用 refresh token 就可以把a(bǔ)ccess token 的有效期縮短,在提高安全性的同時還保證了用戶體驗(yàn)。
四、具體實(shí)現(xiàn)
這里前端采用 vue3 + axios,后端采用 nest.js 實(shí)現(xiàn)
1. 創(chuàng)建后端登錄接口
要求:登錄成功返回兩個 token,一個用于刷新 token(refresh token
),一個用于訪問 token(access token
)。
1.1 創(chuàng)建 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: [ { // 創(chuàng)建一個別名,方便在 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 創(chuàng)建兩個 jwt 的校驗(yàn)策略
這一步是結(jié)合后續(xù)創(chuàng)建的管道,來對接口的請求進(jìn)行校驗(yàn),例如:@UseGuards(JwtRefreshGuard)
在 PassportStrategy
第二個參數(shù)傳入 name
,為了后續(xù) guard
中進(jìn)行區(qū)分
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進(jìn)行校驗(yàn),會在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進(jìn)行校驗(yàn),會在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 通過依賴注入的方式來實(shí)現(xiàn)模塊之間的使用,也可以局部注冊。只有注冊后,全局的守衛(wèi)才會生效。
// 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進(jìn)行導(dǎo)入。因?yàn)樵趍ain.ts中重構(gòu)官方的logger實(shí)例 // JwtAccessStrategy、JwtRefreshStrategy 策略使其在全局都能使用 providers: [Logger, JwtAccessStrategy, JwtRefreshStrategy], exports: [Logger] }) export class AppModule {}
1.5 創(chuàng)建兩個 jwt 的守衛(wèi)
實(shí)現(xiàn)接口注解,@UseGuards(JwtAccessGuard)
和 @UseGuards(JwtRefreshGuard)
來實(shí)現(xiàn)統(tǒng)一校驗(yàn)
例如:
// xxx.controller.ts // 全局接口 @Controller('edit') @UseGuards(JwtAccessGuard) export class EditController {} // 單獨(dú)接口 @Get('refresh-token') @UseGuards(JwtRefreshGuard) async refreshToken(@Headers('Refresh-Token') refreshToken: string) {}
實(shí)現(xiàn):
// src/guard/jwt-access.guard.ts import { AuthGuard } from '@nestjs/passport' /** * AuthGuard 默認(rèn)為 jwt,也可以在 strategy/jwt.strategy.ts 中修改為其他策略 * JwtAccessStrategy 繼承的 PassportStrategy,在 PassportStrategy 第二個參數(shù)就是 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 實(shí)現(xiàn)刷新 token 接口
刷新 token 接口有兩種思路:
- 接口返回兩個 token,這樣后續(xù)就可以保證這個 長 token(refresh token)永遠(yuǎn)不會過期。
- 接口只返回短 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 中注入的內(nèi)容實(shí)現(xiàn) @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 配置請求響應(yīng)攔截器
需要注意的點(diǎn):
- 在攜帶 access token 的接口,返回 401 時,就需要發(fā)送 reqRefreshToken 來刷新 token
- 在頁面多個并發(fā)請求時,需要創(chuàng)建一個請求隊(duì)列,當(dāng) token 刷新后重新發(fā)送請求
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) } ) // 已經(jīng)處理過的錯誤碼 const hasErrorCode = [401, 403] // 在刷新token時,如果頁面上有多個請求,當(dāng)token過期后,那這幾個請求都會觸發(fā) reqRefreshToken 來刷新token /** * 1. 需要定義一個變量來標(biāo)記當(dāng)前是否刷新中,避免重復(fù)刷新token * 2. 創(chuàng)建一個請求隊(duì)列,當(dāng)刷新token成功后,需要把隊(duì)列中的請求重新發(fā)送 */ 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) { // 第一個觸發(fā) 401 的請求,刷新 token 并重新發(fā)送隊(duì)列中的請求 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 /** * 如果同時多個請求,在其他幾個請求中,有一個先返回響應(yīng),先響應(yīng)的回執(zhí)行 requestQueue.forEach, * 此時這個 requestQueue 沒有當(dāng)前請求,則需要返回當(dāng)前這個請求,重新去執(zhí)行 * (返回一個Promise會直接去執(zhí)行) */ return request(response.config) } else { // refreshToken過期 user.clearInfo() router.replace('/login') ElMessage.error('登錄已過期,請重新登錄') } } else { // 當(dāng)前請求不是第一個觸發(fā) 401 的請求,則將當(dāng)前為401(token過期的請求)添加到請求隊(duì)列中 return new Promise((resolve) => { requestQueue.push({ config: response.config, resolve }) }) } return } if (response.data?.code === 403) { user.clearInfo() router.replace('/login') ElMessage.error('無權(quán)限') 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
到此這篇關(guān)于vue中使用雙token機(jī)制的文章就介紹到這了,更多相關(guān)vue 雙token機(jī)制內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
解決vue路由發(fā)生了跳轉(zhuǎn)但是界面沒有任何反應(yīng)問題
這篇文章主要介紹了解決vue路由發(fā)生了跳轉(zhuǎn)但是界面沒有任何反應(yīng)問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-04-04vue如何實(shí)現(xiàn)點(diǎn)擊選中取消切換
這篇文章主要介紹了vue實(shí)現(xiàn)點(diǎn)擊選中取消切換,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-05-05vue+vuex+axios實(shí)現(xiàn)登錄、注冊頁權(quán)限攔截
下面小編就為大家分享一篇vue+vuex+axios實(shí)現(xiàn)登錄、注冊頁權(quán)限攔截,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-03-03Vue.js Ajax動態(tài)參數(shù)與列表顯示實(shí)現(xiàn)方法
Vue.js是一個輕巧、高性能、可組件化的MVVM庫,同時擁有非常容易上手的API。下面通過本文給大家介紹vue.js ajax動態(tài)參數(shù)與列表顯示實(shí)現(xiàn)方法,感興趣的朋友一起看看吧2016-10-10vue為什么v-for的優(yōu)先級比v-if高原理解析
這篇文章主要為大家介紹了vue為什么v-for的優(yōu)先級比v-if高原理解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-05-05vue項(xiàng)目如何實(shí)現(xiàn)前端預(yù)覽word與pdf格式文件
最近項(xiàng)目中需要在線預(yù)覽WORD文檔,所以給大家總結(jié)下,這篇文章主要給大家介紹了關(guān)于vue項(xiàng)目如何實(shí)現(xiàn)前端預(yù)覽word與pdf格式文件的相關(guān)資料,需要的朋友可以參考下2023-03-03Vue 實(shí)現(xiàn)列表動態(tài)添加和刪除的兩種方法小結(jié)
今天小編就為大家分享一篇Vue 實(shí)現(xiàn)列表動態(tài)添加和刪除的兩種方法小結(jié),具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-09-09