雙Token實現無感刷新登錄狀態
基于access_token和refresh_token實現無感刷新登錄狀態
雙token原理
這是登錄認證的流程:

驗證通過之后,將用戶信息放到jwt中。
訪問接口的時候帶上jwt,在Guard里取出來判斷是否有效,jwt有效的話才能繼續訪問:

這種方式有個問題:
jwt是有有效期的,我們設置的是7天,實際上為了安全考慮會設置的很短,比如30分鐘。
可能用戶正在訪問某個界面的時候,jwt突然失效了,必須重新登錄。
體驗比較差。
為了解決這個問題,服務端一般返回兩個token:access_token和refresh_token
access_token是用來認證身份的,之前我們返回的就是這個token
refresh_token是用來刷新token的
服務端會返回新的 access_token和refresh_token,也就是這樣的流程:
登錄成功后,返回兩個token:

access_token用來做登錄權限:

而refresh_token用來刷新,拿到新的token:

access_token設置為30分鐘過期,而refresh_token設置7天過期。
這樣7天內,如果access_token過期了,那就可以用refresh_token來刷新下,拿到新的token
只要不超過七天內未訪問系統,那就可以一直是登錄狀態,可以無限續簽,不需要登錄。
如果超過七天內未訪問系統,那么refresh_token也就過期了,這時候需要重新登錄了。
這也是一般App采用的雙token驗證。
nest.js中的雙token實現
創建一個nest項目:
nest new access_token_and_refresh_token -p npm
創建一個user模塊:
nest g resource user --no-spec
安裝tpyeOrm的依賴:
npm install --save @nestjs/typeorm typeorm mysql2
然后再mySql中建立對應的數據庫:
CREATE DATABASE refresh_token_test DEFAULT CHARACTER SET utf8mb4;
然后建立User的entity:
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50 })
username: string;
@Column({ length: 50 })
password: string;
}
然后在appModule.ts中的entitys添加User:

把服務器跑起來:
npm run start:dev
user表會在mySQL中生成;
然后在UserController添加post類型的login接口:
@Post('/login')
login(@Body() LoginUser: LoginUserDto) {
console.log(LoginUser);
return 'success';
}
創建login-user.dto.ts:
export class LoginUserDto {
username: string;
password: string;
}
訪問測試下:


然后實現登錄邏輯
在UserService里添加login方法:
async login(loginUserDto: LoginUserDto) {
const user = await this.entityManyager.findOne(User, {
where: {
username: loginUserDto.username,
},
});
if (!user) {
throw new HttpException('用戶不存在', HttpStatus.OK);
}
if (user.password !== loginUserDto.password) {
throw new HttpException('密碼錯誤', HttpStatus.OK);
}
return user;
}
然后登陸成功之后我們要返回兩個token;
我們引入jwt的包:
npm install --save @nestjs/jwt
然后在AppModule中引入JwtModule,設置為全局模塊,指定默認的過期時間和密鑰:
JwtModule.register({
global: true,
signOptions: {
expiresIn: '30m'
},
secret: 'guang'
})
然后在UserContrller中生成兩個token返回:
@Inject(JwtService)
private jwtService: JwtService;
@Post('login')
async login(@Body() loginUser: LoginUserDto) {
const user = await this.userService.login(loginUser);
const access_token = this.jwtService.sign({
userId: user.id,
username: user.username,
}, {
expiresIn: '30m'
});
const refresh_token = this.jwtService.sign({
userId: user.id
}, {
expiresIn: '7d'
});
return {
access_token,
refresh_token
}
}
access_token 里存放 userId、username,refresh_token 里只存放 userId 就好了。
過期時間一個 30 分鐘,一個 7 天。
訪問下user/login接口試試:

可以看到兩個token都正確的返回了。
接下來再實現LoginGuard來做登錄鑒權:
nest g guard login --flat --no-spec
登錄邏輯和之前文章寫過的一樣:
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Observable } from 'rxjs';
import { Request } from 'express';
@Injectable()
export class LoginGuard implements CanActivate {
@Inject(JwtService)
private jwtService: JwtService;
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request: Request = context.switchToHttp().getRequest();
const authorization = request.headers.authorization;
if (!authorization) {
throw new UnauthorizedException('用戶未登錄');
}
try {
const token = authorization.split(' ')[1];
const data = this.jwtService.verify(token);
return true;
} catch (e) {
throw new UnauthorizedException('token失效,請重新登錄');
}
}
}
取出authorization header中的jwt token,這個就是access_token,對他做校驗。
jwt有效就可以繼續訪問,否則就返回token失效,請重新登錄。
然后再AppController添加接口并加上登錄鑒權:
@Get('aaa')
aaa() {
return 'aaa';
}
@Get('bbb')
@UseGuards(LoginGuard)
bbb() {
return 'bbb';
}
aaa接口可以直接訪問,bbb接口需要登錄才能訪問。
在user表中添加一條記錄:
INSERT INTO `refresh_token_test`.`user` ( `username`, `password`)
VALUES ( 'guang', '123456');
我們來測試下:


鑒權邏輯生效了!
然后我們登陸下:

把access_token復制下來,加到header里再訪問一下bbb:

可以成功訪問bbb;
現在的access_token是30分鐘過期,30分鐘之后就需要重新登錄了。
這樣顯然體驗不好,接下來實現用refresh_token來刷新的邏輯:
@Get('refresh')
async refresh(@Query('refresh_token') refreshToken: string) {
try {
const data = this.jwtService.verify(refreshToken);
const user = await this.userService.findUserById(data.userId);
const access_token = this.jwtService.sign(
{ userId: user.id, username: user.username },
{ expiresIn: '30m' },
);
const refresh_token = this.jwtService.sign(
{ userId: user.id },
{ expiresIn: '7d' },
);
return {
access_token,
refresh_token,
};
} catch (error) {
throw new UnauthorizedException('token已失效,請重新登錄');
}
}
取出refresh_token里的userId,從數據庫中把user信息查出來,然后生成新的access_token和refresh_token返回。
如果jwt校驗失效,就返回token已失效,請重新登錄。
在UserService中實現下這個findUserById的方法:
async findUserById(userId: number) {
return await this.entityManyager.findOne(User, {
where: { id: userId },
});
}
測試下:
帶上有效的refresh_token,能夠拿到新的access_token和refresh_token:

refresh_token失效或者錯誤時,會返回401的響應碼,提示需要重新登錄:

這樣就實現了雙token的登錄鑒權機制;
只要 7 天內帶上 refresh_token 來拿到新的 token,就可以一直保持登錄狀態。
那前端代碼里訪問接口的時候怎么用這倆 token 呢?
我們新建個 react 項目試一下:
yarn create vite refresh_token_test --template vue
安裝axios:
npm install --save axios
在App.tsx里訪問下/aaa、/bbb接口:
import axios from 'axios';
import { useEffect, useState } from 'react';
function App() {
const [aaa, setAaa] = useState();
const [bbb, setBbb] = useState();
async function query() {
const { data: aaaData } = await axios.get('http://localhost:3000/aaa');
const { data: bbbData } = await axios.get('http://localhost:3000/bbb');
setAaa(aaaData);
setBbb(bbbData);
}
useEffect(() => {
query();
}, [])
return (
<div>
<p>{aaa}</p>
<p>{bbb}</p>
</div>
);
}
export default App;
在服務端開啟一下跨域支持:

把前端項目跑起來:

我們先的登錄一下,拿到access_token,然后再請求的時候帶上:

import axios from "axios";
import { useEffect, useState } from "react";
function App() {
const [aaa, setAaa] = useState();
const [bbb, setBbb] = useState();
async function login() {
const res = await axios.post("http://localhost:3000/user/login", {
username: "guang",
password: "123456",
});
localStorage.setItem("access_token", res.data.access_token);
localStorage.setItem("refresh_token", res.data.refresh_token);
}
async function query() {
await login();
const { data: aaaData } = await axios.get("http://localhost:3000/aaa");
const { data: bbbData } = await axios.get("http://localhost:3000/bbb",{
headers:{
Authorization:'Bearer '+localStorage.getItem('access_token')
}
});
setAaa(aaaData);
setBbb(bbbData);
}
useEffect(() => {
query();
}, []);
return (
<div>
<p>{aaa}</p>
<p>{bbb}</p>
</div>
);
}
export default App;
刷新下,可以看到可以請求bbb接口了:


如果很多接口都需要添加這個header,可以放到interceptors中:

測試下:

也可以正常訪問;
當token失效的時候,要自動刷新,這個也在interceptors里做:
async function refreshToken() {
const res = await axios.get("http://localhost:3000/user/refresh", {
params: { refresh_token: localStorage.getItem("refresh_token") },
});
localStorage.setItem("access_token", res.data.access_token || "");
localStorage.setItem("refresh_token", res.data.refresh_token || "");
return res;
}
axios.interceptors.response.use(
(response) => response,
async (err) => {
let { data, config } = err.response;
if (data.statusCode === 401 && config.url.includes("/user/refresh")) {
const res = await refreshToken();
if (res.status === 200) {
return axios(config);
} else {
alert("登錄過期,請重新登錄");
return Promise.reject(res.data);
}
} else {
return err.response;
}
}
);
如果返回的錯誤是 401 就刷新 token,這里要排除掉刷新的 url,刷新失敗不繼續刷新。
如果刷新接口返回的是 200,就用新 token 調用之前的接口
如果返回的是 401,那就返回這個錯誤。
判斷下如果沒有 access_token 才登錄:

然后手動修改一下access_token的值,讓他失效:

可以看到請求bbb失敗時候,重新刷新了token,之后再次訪問bbb

這樣,我們就實現了 access_token 的無感刷新。
浙公網安備 33010602011771號