前面实现了基于 JWT 的登录,流程是这样的:登录认证通过之后,把用户信息放到 jwt 里返回;访问接口的时候带上 jwt,在 Guard 里取出来判断是否有效,有效的话才能继续访问。
但是这样有个问题:jwt 是有有效期的,设置的是 7 天,实际上为了安全考虑会设置的很短,比如 30 分钟。这时候用户可能还在访问系统的某个页面,结果访问某个接口返回 token 失效了,让重新登录,体验是不是就很差?
为了解决这个问题,主要有两种方案:
这两种方案都是用得特别多的方案。
为了解决 token 失效导致体验差的问题,服务端一般会返回两个 token:access_token 和 refresh_token。
流程如下:登录成功之后,返回两个 token。access_token 用来做登录鉴权,refresh_token 用来刷新拿到新 token。
access_token 设置 30 分钟过期,而 refresh_token 设置 7 天过期。这样 7 天内,如果 access_token 过期了,那就可以用 refresh_token 刷新下,拿到新 token。只要不超过 7 天未访问系统,就可以一直是登录状态,可以无限续签,不需要登录。如果超过 7 天未访问系统,那 refresh_token 也就过期了,这时候就需要重新登录了。
想想你常用的 app,登录过几次?是不是常用的 app 基本不用重新登录?如果你超过一段时间没使用这个 app,是不是又会让你重新登录了?一般 app 里用的就是这种双 token 来做登录鉴权。
创建个 nest 项目并添加 user 模块:
nest new access_token_and_refresh_token -p npm
nest g resource user --no-spec安装 typeorm 的依赖:
npm install --save @nestjs/typeorm typeorm mysql2在 AppModule 引入 TypeOrmModule:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [
TypeOrmModule.forRoot({
type: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "guang",
database: "refresh_token_test",
synchronize: true,
logging: true,
entities: [],
poolSize: 10,
connectorPackage: 'mysql2',
extra: {
authPlugin: 'caching_sha2_password',
}
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}在 mysql workbench 里创建用到的 database:
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;
}在 entities 里添加 User,然后把服务跑起来:
npm run start:dev然后在 UserController 添加 login 的 post 接口,创建 src/user/dto/login-user.dto.ts:
@Post('login')
login(@Body() loginUser: LoginUserDto) {
console.log(loginUser);
return 'success';
}export class LoginUserDto {
username: string;
password: string;
}在 UserService 里添加 login 方法:
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { LoginUserDto } from './dto/login-user.dto';
import { User } from './entity/user.entity';
@Injectable()
export class UserService {
@InjectEntityManager()
private entityManager: EntityManager;
async login(loginUserDto: LoginUserDto) {
const user = await this.entityManager.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'
})然后在 UserController 生成两个 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 天。
再实现 LoginGuard 来做登录鉴权:
nest g guard login --flat --no-spec登录鉴权逻辑和之前一样:
import { JwtService } from '@nestjs/jwt';
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { Observable } from 'rxjs';
@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` (`id`, `username`, `password`)
VALUES ('1', 'guang', '123456');登录后把 access_token 加到 header 里才能访问 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(e) {
throw new UnauthorizedException('token 已失效,请重新登录');
}
}取出 refresh_token 里的 userId,从数据库中把 user 信息查出来,然后生成新的 access_token 和 refresh_token 返回。如果 jwt 校验失败,就返回 token 已失效,请重新登录。
在 UserService 实现下这个 findUserById 的方法:
async findUserById(userId: number) {
return await this.entityManager.findOne(User, {
where: {
id: userId
}
});
}带上有效的 refresh_token,能够拿到新的 access_token 和 refresh_token;refresh_token 失效或者错误时,会返回 401 的响应码,提示需要重新登录。这样,只要 7 天内带上 refresh_token 来拿到新的 token,就可以一直保持登录状态。
新建个 react 项目并安装 axios:
npm create vite@latest refresh_token_test -- --template react-ts
npm install --save axios在 App.tsx 里访问下 /aaa、/bbb 接口,并在服务端开启跨域支持(去掉 React.StrictMode,避免请求两次):
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;如果很多接口都要添加这个 header,可以把它放在 interceptors 里做:
axios.interceptors.request.use(function (config) {
const accessToken = localStorage.getItem('access_token');
if(accessToken) {
config.headers.authorization = 'Bearer ' + accessToken;
}
return config;
})再就是当 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) => {
return response;
},
async (error) => {
let { data, config } = error.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 error.response;
}
}
)如果返回的错误是 401 就刷新 token,这里要排除掉刷新的 url,刷新失败不继续刷新。如果刷新接口返回的是 200,就用新 token 调用之前的接口;如果返回的是 401,那就返回这个错误。
然后判断下如果没有 access_token 才登录:
if(!localStorage.getItem('access_token')) {
await login();
}手动改下 access_token 的值让它失效,刷新页面后,访问 aaa、bbb 接口,bbb 接口 401 了,于是 refresh token,之后再次访问 bbb。这样,就实现了 access_token 的无感刷新。
这样还不完美,比如当并发多个请求的时候,如果都失效了,是不是要刷新多次?
加个并发请求试一下:
await Promise.all([
axios.get('http://localhost:3000/bbb'),
axios.get('http://localhost:3000/bbb'),
axios.get('http://localhost:3000/bbb')
]);确实刷新了多次。其实这样不处理也行,多刷几次也不影响功能。但做的完美点还是要处理下:
加一个 refreshing 的标记,如果在刷新,那就返回一个 promise,并且把它的 resolve 方法还有 config 加到队列里。当 refresh 成功之后,修改 refreshing 的值,重新发送队列中的请求,并且把结果通过 resolve 返回。
interface PendingTask {
config: AxiosRequestConfig
resolve: Function
}
let refreshing = false;
const queue: PendingTask[] = [];
axios.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
let { data, config } = error.response;
if(refreshing) {
return new Promise((resolve) => {
queue.push({
config,
resolve
});
});
}
if (data.statusCode === 401 && !config.url.includes('/user/refresh')) {
refreshing = true;
const res = await refreshToken();
refreshing = false;
if(res.status === 200) {
queue.forEach(({config, resolve}) => {
resolve(axios(config))
})
return axios(config);
} else {
alert('登录过期,请重新登录');
return Promise.reject(res.data);
}
} else {
return error.response;
}
}
)这样,并发请求时 token 过期,就只刷新一次 token,并把请求放到队列里,刷新完批量重发请求。
这节实现了基于 access_token 和 refresh_token 的无感刷新登录状态,也就是无感续签。
access_token 用于身份认证,refresh_token 用于刷新 token,也就是续签。在登录接口里同时返回 access_token 和 refresh_token,access_token 设置较短的过期时间(比如 30 分钟),refresh_token 设置较长的过期时间(比如 7 天)。当 access_token 失效的时候,可以用 refresh_token 去刷新,服务端会根据其中的 userId 查询用户数据,返回新 token。
在前端代码里,登录之后把 token 放在 localstorage 里。用 axios 的 interceptors.request 给请求时自动带上 authorization 的 header,用 interceptors.response 在响应是 401 的时候,自动访问 refreshToken 接口拿到新 token,然后再次访问失败的接口。还支持了并发请求时,如果 token 过期,会把请求放到队列里,只刷新一次,刷新完批量重发请求。
双 token 当然能实现功能,很多公司也这样用。但是双 token 实现起来还是挺麻烦的。所以实际上单 token 自动续期的方式用的也非常多。
单 token 的原理也很简单,就是登录后返回 jwt,每次请求接口带上这个 jwt,然后每次访问接口返回新的 jwt,然后前端更新下本地的 jwt token。
比如这个 token 是 7 天过期,那只要 7 天内访问一次系统,就会刷新 token。7 天内不访问系统,token 过期,就需要重新登录了。这种方案也能实现无感刷新,而且代码简单的多。
nest new single-token-refresh进入项目,添加一个 user 模块:
nest g resource user --no-spec加一个 login 的路由:
import { Body, Controller, Post } from '@nestjs/common';
import { UserService } from './user.service';
import { LoginUserDto } from './dto/login-user.dto';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post('login')
async login(@Body() loginDto: LoginUserDto) {
console.log(loginDto)
}
}对应的 user/dto/login-user.dto.ts:
export class LoginUserDto {
username: string;
password: string;
}把服务跑起来并安装用到的包:
npm run start:dev
npm install --save @nestjs/jwt在 AppModule 引入:
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [UserModule,
JwtModule.register({
global: true,
secret: 'guang'
})
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
然后 login 的时候返回 jwt:
import { BadRequestException, Body, Controller, Inject, Post } from '@nestjs/common';
import { UserService } from './user.service';
import { LoginUserDto } from './dto/login-user.dto';
import { JwtService } from '@nestjs/jwt';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Inject(JwtService)
jwtService: JwtService;
@Post('login')
async login(@Body() loginDto: LoginUserDto) {
if(loginDto.username !== 'guang' || loginDto.password !== '123456') {
throw new BadRequestException('用户名或密码错误');
}
const jwt = this.jwtService.sign({
username: loginDto.username
}, {
secret: 'guang',
expiresIn: '7d'
});
return jwt;
}
}登录后返回 jwt,过期时间是 7 天。
然后加一个 Guard 来解析 jwt:
nest g guard login --flat --no-spec登录鉴权逻辑和之前一样:
import { JwtService } from '@nestjs/jwt';
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { Observable } from 'rxjs';
@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,jwt 有效就可以继续访问,否则返回 token 失效,请重新登录。
然后在 AppController 添加个接口加上登录鉴权:
@Get('aaa')
aaa() {
return 'aaa';
}
@Get('bbb')
@UseGuards(LoginGuard)
bbb() {
return 'bbb';
}登录拿到 token,带上 token 就可以访问需要登录的接口了。
但这个 token 是有过期时间的,过期了就要重新登录了,所以要在 Guard 里实现单 token 刷新。方式很简单,就是访问接口后返回新 token:
import { JwtService } from '@nestjs/jwt';
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Request, Response } from 'express';
import { Observable } from 'rxjs';
@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 response: Response = context.switchToHttp().getResponse();
const authorization = request.headers.authorization;
if(!authorization) {
throw new UnauthorizedException('用户未登录');
}
try{
const token = authorization.split(' ')[1];
const data = this.jwtService.verify(token);
response.setHeader('token', this.jwtService.sign({
username: data.username
}, {
expiresIn: '7d'
}));
return true;
} catch(e) {
throw new UnauthorizedException('token 失效,请重新登录');
}
}
}这样每次返回新 token,不就永不过期了?而且实现还特别简单。所以单 token 自动续期的方案用的挺多的。
写下前端代码:
npx create-vite single-token-refresh-frontend进入项目,把服务跑起来,先让后端开启跨域,然后改下 App.tsx 并安装 axios:
npm install
npm run dev
npm install --save axiosimport { useEffect, useState } from 'react'
import './App.css'
import axios from 'axios';
function App() {
const [content, setContent] = useState('')
async function query() {
try {
const res = await axios.get('http://localhost:3000/bbb');
setContent(res.data);
} catch(e: any) {
console.log(e.response.data.message);
}
}
useEffect(() => {
query();
}, []);
return (
<div style={{fontSize: '100px'}}>{content}</div>
)
}
export default App提示未登录(打印两次是 main.tsx 里的 StrictMode 导致的,去掉就好了)。登入拿 token 带上:
import { useEffect, useState } from 'react'
import './App.css'
import axios from 'axios';
function App() {
const [content, setContent] = useState('')
async function query() {
try {
const res = await axios.post('http://localhost:3000/user/login', {
username: 'guang',
password: '123456'
});
console.log(res.data);
const res2 = await axios.get('http://localhost:3000/bbb', {
headers: {
Authorization: `Bearer ${res.data}`
}
});
setContent(res2.data);
} catch(e: any) {
console.log(e.response.data.message);
}
}
useEffect(() => {
query();
}, []);
return (
<div style={{fontSize: '100px'}}>{content}</div>
)
}
export default App现在接口就请求成功了。这个 token 一般都放到 localstorage 里,每次请求都带上:
axios.interceptors.request.use(function (config) {
const accessToken = localStorage.getItem('access_token');
if(accessToken) {
config.headers.authorization = 'Bearer ' + accessToken;
}
return config;
})那单 token 如何刷新呢?很简单,拦截器里把 header 里的新 token 更新到 localStorage 就好了:
axios.interceptors.response.use(
(response) => {
const newToken = response.headers['token'];
if(newToken) {
localStorage.setItem('token', newToken);
}
return response;
}
)但这样有个问题:打印下 header,没有 token,但明明返回了。这也是跨域的问题,默认你能访问的 header 是有限的。如果想在代码访问别的 header,需要在后端支持下,在 Access-Control-Expose-Headers 里加上这个 header:
Access-Control-Expose-Headers: token现在就可以访问这个 header 了。这样更新完 localStorage 里的 token,不就实现无感刷新了么?
这节实现了单 token 的无感刷新,它也是在公司里用的非常多的一种方案。
好处就是简单,只要每次请求接口的时候返回新的 token,然后刷新下本地 token 就可以了。在 axios 的 response 拦截器里可以轻松做到这个,比双 token 的无感刷新可简单太多了。
要注意的是在代码里访问其他 header,需要后端配置下 expose headers 才可以。
双 token 和单 token 都能实现登录状态的无感刷新:
(说明:单 token 方案更合理的做法是在快过期的时候返回新 token,可根据实际情况优化。)