你目前的认证体系:
登录
↓
JWT
↓
Access Token
↓
访问接口
有一个致命问题:
Access Token过期
↓
401
↓
用户重新登录
真实项目不会这样。
第八阶段:Refresh Token 双 Token体系
目标:
登录
↓
AccessToken
(15分钟)
↓
RefreshToken
(7天)
↓
AccessToken过期
↓
自动刷新
↓
用户无感知
先理解双 Token
登录成功:
{
"access_token":"xxx",
"refresh_token":"yyy"
}
Access Token:
有效期短
15分钟
负责访问接口
Refresh Token:
有效期长
7天
负责换新Token
流程:
登录
↓
access_token
15分钟
↓
过期
↓
POST /auth/refresh
携带refresh_token
↓
签发新的access_token
↓
继续访问
第一步:配置两个 Secret
.env
JWT_ACCESS_SECRET=access-secret
JWT_REFRESH_SECRET=refresh-secret
第二步:登录返回双 Token
当前:
return {
access_token:
await this.jwtService.signAsync(
payload,
),
};
改:
const accessToken =
await this.jwtService.signAsync(
payload,
{
secret:'access-secret',
expiresIn:'15m',
},
);
const refreshToken =
await this.jwtService.signAsync(
payload,
{
secret:'refresh-secret',
expiresIn:'7d',
},
);
return {
access_token:accessToken,
refresh_token:refreshToken,
};
返回:
{
"access_token":"xxx",
"refresh_token":"yyy"
}
第三步:数据库保存 RefreshToken
为什么?
因为:
用户退出登录
↓
RefreshToken失效
如果不存数据库:
永远无法主动失效
User Entity
增加:
@Column({
nullable:true,
})
refreshToken:string;
数据库:
user
id
username
password
role
refreshToken
第四步:保存 RefreshToken
登录成功:
await this.userRepository.update(
user.id,
{
refreshToken,
},
);
完整login
async login(dto: LoginDto) {
const user = await this.usersService.findByUsername(dto.username);
if (!user) {
throw new Error('用户不存在');
}
const isMatch = await bcrypt.compare(dto.password, user.password);
if (!isMatch) {
throw new Error('密码错误');
}
const payload = {
sub: user.id,
username: user.username,
role: user.role,
};
const accessToken = await this.jwtService.signAsync(payload, {
secret: 'access-secret',
expiresIn: '30s',
});
const refreshToken = await this.jwtService.signAsync(payload, {
secret: 'refresh-secret',
expiresIn: '7d',
});
// ⭐ 保存refreshToken
await this.usersService.updateRefreshToken(user.id, refreshToken);
return {
access_token: accessToken,
refresh_token: refreshToken,
};
}
src/users/users.service.ts
async updateRefreshToken(userId: number, refreshToken: string) {
return this.userRepository.update(userId, {
refreshToken,
});
}
src/users/entities/user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { Role } from '../enums/role.enum';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({
unique: true,
})
username: string;
@Column()
password: string;
@Column({
type: 'enum',
enum: Role,
default: Role.USER,
})
role: string;
@Column({
nullable: true,
})
refreshToken: string;
}
第五步:创建刷新接口
src\auth\auth.controller.ts
@Public()
@Post('refresh')
refresh(
@Body()
body:{
refreshToken:string;
},
){
return this.authService.refresh(
body.refreshToken,
);
}
第六步:实现 refresh()
AuthService
async refresh(
refreshToken:string,
){
验证 Token
const payload =
await this.jwtService.verifyAsync(
refreshToken,
{
secret:'refresh-secret',
},
);
得到:
{
"sub":1,
"username":"admin",
"role":"admin"
}
查询用户
const user =
await this.usersService.findOne(
payload.sub,
);
校验数据库中的 Token
if(
user.refreshToken !==
refreshToken
){
throw new UnauthorizedException();
}
重新签发 AccessToken
const newAccessToken =
await this.jwtService.signAsync(
{
sub:user.id,
username:user.username,
role:user.role,
},
{
secret:'access-secret',
expiresIn:'15m',
},
);
返回:
{
"access_token":"new-token"
}
src\auth\auth.service.ts 完整refresh
src\users\users.service.ts
findOne(id: number) {
return this.userRepository.findOne({
where: {
id,
},
});
}
async refresh(refreshToken: string) {
const payload = await this.jwtService.verifyAsync<JwtPayload>(
refreshToken,
{
secret: 'refresh-secret',
},
);
const user = await this.usersService.findOne(payload.sub);
if (!user || user.refreshToken !== refreshToken) {
throw new UnauthorizedException('Refresh token 无效');
}
const accessToken = await this.jwtService.signAsync(
{
sub: user.id,
username: user.username,
role: user.role,
},
{
secret: 'access-secret',
expiresIn: '15m',
},
);
return {
access_token: accessToken,
};
}
完整src\auth\auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { LoginDto } from './dto/login.dto';
import * as bcrypt from 'bcrypt';
import { JwtService } from '@nestjs/jwt';
import { JwtPayload } from './interfaces/jwt-payload.interface';
import { UnauthorizedException } from '@nestjs/common';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
async register(dto: LoginDto) {
const hashedPassword = await bcrypt.hash(dto.password, 10);
return this.usersService.create(dto.username, hashedPassword);
}
async login(dto: LoginDto) {
const user = await this.usersService.findByUsername(dto.username);
if (!user) {
throw new Error('用户不存在');
}
const isMatch = await bcrypt.compare(dto.password, user.password);
if (!isMatch) {
throw new Error('密码错误');
}
const payload = {
sub: user.id,
username: user.username,
role: user.role,
};
const accessToken = await this.jwtService.signAsync(payload, {
secret: 'access-secret',
expiresIn: '30s',
});
const refreshToken = await this.jwtService.signAsync(payload, {
secret: 'refresh-secret',
expiresIn: '7d',
});
// ⭐ 保存refreshToken
await this.usersService.updateRefreshToken(user.id, refreshToken);
return {
access_token: accessToken,
refresh_token: refreshToken,
};
}
async refresh(refreshToken: string) {
const payload = await this.jwtService.verifyAsync<JwtPayload>(
refreshToken,
{
secret: 'refresh-secret',
},
);
const user = await this.usersService.findOne(payload.sub);
if (!user || user.refreshToken !== refreshToken) {
throw new UnauthorizedException('Refresh token 无效');
}
const accessToken = await this.jwtService.signAsync(
{
sub: user.id,
username: user.username,
role: user.role,
},
{
secret: 'access-secret',
expiresIn: '15m',
},
);
return {
access_token: accessToken,
};
}
}
测试
第一步:登录获取 Token
打开 Postman。
创建:
POST
http://localhost:3000/auth/login
Headers
不用设置。
Body
选择:
raw
JSON
输入:
{
"username":"admin",
"password":"123456"
}
点击:
Send
成功:
返回:
{
"access_token":
"eyJhbGciOiJIUzI1NiIs...",
"refresh_token":
"eyJhbGciOiJIUzI1NiIs..."
}
复制两个:
保存:
ACCESS_TOKEN
REFRESH_TOKEN
第二步:测试 access_token 访问 books
请求:
GET
http://localhost:3000/books
进入:
Headers
添加:
| Key | Value |
|---|---|
| Authorization | Bearer access_token |
注意:
必须:
Bearer 空格 token
例如:
Bearer eyJhbGciOiJIUzI1...
点击 Send。
成功:
返回:
[
{
"id":1,
"name":"NestJS"
}
]
说明:
access_token
↓
JwtAuthGuard
↓
JwtStrategy
↓
validate()
↓
books接口
成功。
第三步:模拟 access_token 过期
你的:
expiresIn:'15m'
测试不方便。
临时改:
expiresIn:'10s'
重新登录。
等待10秒。
再次访问:
GET /books
Header:
还是:
Authorization: Bearer 旧access_token
返回:
{
"statusCode":401,
"message":"Unauthorized"
}
说明:
access_token失效。
第四步:使用 refresh_token 换新 access_token
创建请求:
POST
http://localhost:3000/auth/refresh
Headers:
不用。
Body:
{
"refreshToken":
"你的refresh_token"
}
例如:
{
"refreshToken":
"eyJhbGciOiJIUzI1Ni..."
}
点击 Send。
返回:
{
"access_token":
"新的token"
}
复制:
NEW_ACCESS_TOKEN
第五步:使用新 access_token 访问 books
重新请求:
GET
http://localhost:3000/books
Headers:
替换:
之前:
Bearer 旧token
改:
Bearer 新token
点击:
Send。
成功:
[
{
"id":1,
"name":"xxx"
}
]
第六步:测试错误 refreshToken
故意传错:
{
"refreshToken":"abc123"
}
请求:
POST /auth/refresh
返回:
{
"statusCode":401,
"message":"Unauthorized"
}
说明校验成功。
第七步:检查数据库
打开 MySQL:
select * from user;
应该:
| id | username | refreshToken |
|---|---|---|
| 1 | admin | eyJhbGci... |
整个测试流程图
POST /auth/login
↓
返回
access_token
refresh_token
↓
GET /books
带 access_token
↓
成功
↓
access过期
↓
POST /auth/refresh
带 refresh_token
↓
返回新的 access_token
↓
GET /books
带新的 access_token
↓
成功
如果这一套测试通过,你现在的 Nest 项目已经具备:
用户系统
+
JWT认证
+
RBAC权限
+
Refresh Token续签

562

被折叠的 条评论
为什么被折叠?



