用 NestJS 从零开发一个完整的小项目:图书管理系统(第八阶段:Refresh Token 双 Token 体系)

你目前的认证体系:


登录
 ↓
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

添加:

KeyValue
AuthorizationBearer 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;

应该:

idusernamerefreshToken
1admineyJhbGci...

整个测试流程图


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续签


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值