前后端双令牌认证(Access Token + Refresh Token)全方案实现:安全与体验兼得

0 阅读21分钟

前后端双令牌认证(Access Token + Refresh Token)全方案实现:安全与体验兼得

在前后端分离架构中,用户认证是保障系统安全的核心环节。传统的单令牌认证方案(如仅使用Access Token)存在明显弊端:若令牌长期有效,一旦泄露会导致用户信息被非法获取;若令牌短期有效,又会频繁要求用户重新登录,严重影响使用体验。为解决这一矛盾,Access Token + Refresh Token(双令牌)认证方案应运而生,它既能兼顾安全性,又能保障用户登录态的连续性,成为当前前后端分离项目中最主流的认证方式之一。

本文将从整体设计思路出发,详细拆解基于NestJS后端和Axios前端的双令牌认证全流程实现,包括双令牌的签发与校验、密码安全存储、前端无感刷新机制、并发问题处理以及关键安全细节,助力开发者快速掌握并落地这一成熟的认证方案。

一、整体设计思路:双令牌的核心逻辑与优势

双令牌方案的核心思想是“分工协作、风险隔离”,通过两个功能、时效、使用场景完全不同的令牌,平衡系统安全与用户体验。具体设计如下:

1. 双令牌定义与使用场景

  • Access Token(访问令牌) :短期有效,默认设置为15分钟。它的核心作用是“访问业务接口”,每次前端发起业务请求时,需在请求头中携带该令牌(格式:Authorization: Bearer ),后端通过校验该令牌的合法性,判断用户是否有权限访问接口。
  • Refresh Token(刷新令牌) :长期有效,默认设置为7天。它的唯一作用是“换取新的双令牌”,不随每次业务请求发送,仅在Access Token过期时使用。后端会对Refresh Token进行严格校验,且支持令牌轮换(每次刷新后,旧的Refresh Token失效,生成新的Refresh Token并存储)。

2. 设计初衷与优势

双令牌方案的设计,本质上是对“安全性”和“用户体验”的权衡,相比单令牌方案,具有以下核心优势:

  • 降低泄露风险:Access Token短期有效,即便不慎泄露,攻击者可利用该令牌的时间窗口极短(15分钟),影响范围被严格控制;而Refresh Token仅用于换取新令牌,使用场景单一,且有额外的安全存储和校验机制。
  • 保障登录态连续:用户无需频繁登录,当Access Token过期时,前端可自动使用Refresh Token换取新的双令牌,整个过程用户无感知,既避免了频繁登录的麻烦,又保证了登录态的连续性。
  • 便于登录态控制:Refresh Token在服务端有存储和校验机制,当用户登出、密码修改或检测到异常时,后端可直接清空或失效用户的Refresh Token,快速终止用户登录态,提升系统安全性。

简单来说,Access Token负责“短期访问”,Refresh Token负责“长期续期”,二者分工明确、相互配合,既解决了单令牌的安全隐患,又优化了用户体验。

二、后端实现(NestJS):双令牌的签发、校验与刷新

后端作为认证逻辑的核心,主要负责双令牌的签发、密码的安全存储、Refresh Token的校验与轮换,以及提供对应的接口供前端调用。本文基于NestJS框架实现,结合JWT(JSON Web Token)和bcrypt加密工具,完成全流程开发。

1. 环境准备

首先需安装相关依赖,确保NestJS项目中具备JWT认证和密码加密的能力:

# 安装JWT相关依赖
npm install @nestjs/jwt @nestjs/passport passport-jwt
# 安装密码加密依赖
npm install bcrypt
# 安装类型声明(TypeScript项目)
npm install -D @types/bcrypt

2. 双令牌签发与时效控制

在NestJS的auth模块(src/auth)中,创建auth.service.ts文件,实现getTokens方法,用于同时生成Access Token和Refresh Token。核心要点是:两个令牌使用不同的密钥、不同的过期时间,通过Promise.all并发生成,提升生成效率。

首先定义JWT常量(jwt.constants.ts),分离两个令牌的密钥,避免密钥泄露后同时影响两个令牌:

// src/auth/jwt.constants.ts
export const jwtConstants = {
  secret: 'access-token-secret-key', // Access Token密钥(需替换为实际项目中的随机密钥)
  refreshSecret: 'refresh-token-secret-key', // Refresh Token密钥(与Access密钥不同)
};

然后在auth.service.ts中实现getTokens方法,接收用户基础信息(ID、用户名、头像、简介),生成两个不同的JWT令牌:

// src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from './jwt.constants';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(private jwtService: JwtService) {}

  // 生成双令牌(Access Token + Refresh Token)
  async getTokens(
    userId: string | number | bigint,
    username: string,
    avatarUrl: string | null = null,
    bio: string | null = null,
  ) {
    // 并发生成两个令牌,提升效率
    const [accessToken, refreshToken] = await Promise.all([
      // 生成Access Token:15分钟过期,使用access密钥
      this.jwtService.signAsync(
        { sub: userId.toString(), username, avatarUrl, bio }, //  payload中携带用户基础信息
        { secret: jwtConstants.secret, expiresIn: '15m' },
      ),
      // 生成Refresh Token:7天过期,使用refresh密钥
      this.jwtService.signAsync(
        { sub: userId.toString(), username }, // payload中仅携带核心信息,减少冗余
        { secret: jwtConstants.refreshSecret, expiresIn: '7d' },
      ),
    ]);

    return { accessToken, refreshToken };
  }
}

关键细节说明:

  • 两个令牌使用不同的密钥(jwtConstants.secret和jwtConstants.refreshSecret),即使其中一个密钥泄露,也不会影响另一个令牌的安全性,降低了整体风险。
  • Access Token的payload中可携带用户常用信息(如头像、简介),减少后续业务接口查询用户信息的次数;Refresh Token的payload仅携带核心标识(用户ID、用户名),避免冗余,同时降低信息泄露风险。
  • 通过Promise.all并发生成两个令牌,相比串行生成,能有效提升令牌生成效率,尤其在高并发场景下效果明显。

3. 密码安全:bcrypt哈希存储与校验

用户密码是系统安全的第一道防线,绝对不能以明文形式存储在数据库中。本文采用bcrypt加密算法,对密码进行哈希处理后存储,登录时通过哈希比对完成密码校验,符合行业安全实践。

3.1 密码哈希存储(注册场景)

在用户注册时,获取前端传入的明文密码,通过bcrypt.hash方法进行哈希处理,然后将哈希后的密码存入数据库,不存储任何明文密码。同时实现hashData工具方法,统一处理哈希逻辑:

// src/auth/auth.service.ts(补充hashData方法和注册逻辑)
// 哈希处理工具方法
private async hashData(data: string) {
  return bcrypt.hash(data, 10); // 盐值 rounds=10,盐由bcrypt自动生成
}

// 用户注册逻辑
async register(createUserDto: any) {
  // 1. 对密码进行哈希处理(若密码为空,需做异常处理,此处简化)
  const hashedPassword = await this.hashData(createUserDto.password || '');

  // 2. 调用用户服务,创建新用户(密码存入哈希值)
  const newUser = await this.usersService.create({
    ...createUserDto,
    password: hashedPassword, // 存储哈希后的密码,而非明文
  });

  // 3. 为新用户生成双令牌
  const tokens = await this.getTokens(newUser.userId, newUser.username, newUser.avatarUrl, newUser.bio);

  // 4. 存储Refresh Token的哈希值(后续详解)
  await this.updateRefreshToken(newUser.userId, tokens.refreshToken);

  // 5. 返回用户信息(隐藏密码)和令牌
  const { password, ...userInfo } = newUser;
  return { userInfo, ...tokens };
}

3.2 密码校验(登录场景)

用户登录时,前端传入明文密码,后端从数据库中获取该用户的密码哈希值,通过bcrypt.compare方法比对明文密码与哈希值,判断密码是否正确:

// src/auth/auth.service.ts(登录逻辑)
async login(data: { username: string; password: string }) {
  // 1. 查询用户是否存在
  const user = await this.usersService.findOneByUsername(data.username);
  if (!user) {
    throw new UnauthorizedException('用户名或密码错误');
  }

  // 2. 比对明文密码与数据库中的哈希值
  const passwordMatches = await bcrypt.compare(data.password, user.password);
  if (!passwordMatches) {
    throw new UnauthorizedException('用户名或密码错误');
  }

  // 3. 生成双令牌
  const tokens = await this.getTokens(user.userId, user.username, user.avatarUrl, user.bio);

  // 4. 存储Refresh Token的哈希值
  await this.updateRefreshToken(user.userId, tokens.refreshToken);

  // 5. 返回用户信息(隐藏密码)和令牌
  const { password, ...userInfo } = user;
  return { userInfo, ...tokens };
}

关键细节说明:

  • bcrypt.hash方法的第二个参数(rounds=10)表示盐值的复杂度,值越高,哈希过程越慢,安全性越高,但性能消耗也越大,10是行业常用的平衡值。
  • bcrypt会自动生成随机盐值,即使两个用户的明文密码相同,哈希后的结果也会不同,有效防止彩虹表攻击。
  • 登录时不直接返回密码错误的具体原因(如“密码错误”或“用户名不存在”),而是统一返回“用户名或密码错误”,避免攻击者通过错误信息枚举用户名。

4. Refresh Token的存储与校验:双重安全保障

Refresh Token作为长期有效的令牌,其安全性至关重要。若Refresh Token以明文形式存储在数据库中,一旦数据库泄露,攻击者可直接使用该令牌换取Access Token,进而获取用户信息。因此,我们对Refresh Token也进行哈希处理,仅存储哈希值,校验时通过哈希比对确认合法性,同时实现令牌轮换机制,进一步提升安全性。

4.1 Refresh Token哈希存储

实现updateRefreshToken方法,将前端传来的Refresh Token进行哈希处理后,存入用户表的refreshToken字段(需在用户表中添加该字段):

// src/auth/auth.service.ts(补充updateRefreshToken方法)
async updateRefreshToken(userId: string | number | bigint, refreshToken: string) {
  // 对Refresh Token进行哈希处理
  const hashedRefreshToken = await this.hashData(refreshToken);
  // 更新用户表中的refreshToken字段,存储哈希值
  await this.usersService.update(userId, { refreshToken: hashedRefreshToken });
}

该方法会在用户登录、令牌刷新时调用,确保数据库中始终存储的是Refresh Token的哈希值,而非明文。

4.2 Refresh Token校验与令牌轮换

实现refreshTokens方法,接收前端传入的userId和明文Refresh Token,通过以下步骤完成校验并换取新的双令牌:

// src/auth/auth.service.ts(补充refreshTokens方法)
async refreshTokens(userId: string, refreshToken: string) {
  // 1. 查询用户是否存在,且是否有存储的Refresh Token哈希值
  const user = await this.usersService.findOne(userId);
  if (!user || !user.refreshToken) {
    throw new ForbiddenException('Access Denied');
  }

  // 2. 比对前端传入的明文Refresh Token与数据库中的哈希值
  const refreshTokenMatches = await bcrypt.compare(refreshToken, user.refreshToken);
  if (!refreshTokenMatches) {
    throw new ForbiddenException('Access Denied');
  }

  // 3. 生成新的双令牌(令牌轮换)
  const tokens = await this.getTokens(user.userId, user.username, user.avatarUrl, user.bio);

  // 4. 更新数据库中的Refresh Token哈希值(旧的Refresh Token失效)
  await this.updateRefreshToken(user.userId, tokens.refreshToken);

  // 5. 返回新的双令牌
  return tokens;
}

关键细节说明:

  • 令牌轮换:每次刷新令牌时,都会生成新的Access Token和Refresh Token,同时将新的Refresh Token哈希值写入数据库,旧的Refresh Token哈希值被覆盖,导致旧的Refresh Token失效。这种机制能有效防止Refresh Token被盗用后,攻击者持续使用该令牌换取Access Token。
  • 双重校验:既校验用户是否存在、是否有有效的Refresh Token哈希值,又校验前端传入的明文Refresh Token与哈希值是否匹配,确保Refresh Token的合法性。
  • 异常处理:若用户不存在、没有存储的Refresh Token,或Refresh Token比对失败,均返回Access Denied,拒绝刷新令牌,避免非法请求。

5. 接口实现:刷新接口与业务接口鉴权

后端需要提供两个核心接口:刷新令牌接口(供前端在Access Token过期时调用)和业务接口(需通过Access Token鉴权)。

5.1 刷新接口(无鉴权)

在auth.controller.ts中实现POST /auth/refresh接口,该接口无需鉴权(因为Access Token已过期,用户无法通过鉴权),仅校验请求体中的userId和refreshToken,调用refreshTokens方法返回新的双令牌:

// src/auth/auth.controller.ts
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  // 刷新令牌接口:无鉴权
  @Post('refresh')
  refreshTokens(@Body() body: { userId: string; refreshToken: string }) {
    // 校验请求体参数是否完整
    if (!body.userId || !body.refreshToken) {
      throw new UnauthorizedException('Invalid refresh token request');
    }
    // 调用服务层方法,刷新令牌
    return this.authService.refreshTokens(body.userId, body.refreshToken);
  }

  // 注册接口(简化)
  @Post('register')
  register(@Body() createUserDto: any) {
    return this.authService.register(createUserDto);
  }

  // 登录接口(简化)
  @Post('login')
  login(@Body() data: { username: string; password: string }) {
    return this.authService.login(data);
  }
}

5.2 业务接口鉴权

业务接口(如获取用户信息、提交订单等)需要通过Access Token鉴权,NestJS可通过JWT Guard实现鉴权逻辑。首先创建jwt.strategy.ts,定义Access Token的校验策略:

// src/auth/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { jwtConstants } from './jwt.constants';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      // 从请求头中提取Access Token
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      // 不忽略过期时间
      ignoreExpiration: false,
      // 使用Access Token的密钥进行校验
      secretOrKey: jwtConstants.secret,
    });
  }

  // 校验通过后,将payload中的信息挂载到request对象上
  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}

然后在auth.module.ts中引入JwtModule、PassportModule,并注册JwtStrategy:

// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
import { jwtConstants } from './jwt.constants';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '15m' },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

最后,在业务控制器中使用@UseGuards(JwtGuard)装饰器,即可实现对业务接口的Access Token鉴权:

// src/users/users.controller.ts(示例)
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtGuard } from '../auth/guards/jwt.guard'; // 需创建JwtGuard
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  // 需Access Token鉴权的接口
  @Get('profile')
  @UseGuards(JwtGuard)
  getProfile(@Request() req) {
    // req.user中包含JwtStrategy校验后的用户信息(userId、username)
    return this.usersService.findOne(req.user.userId);
  }
}

关键细节:业务接口鉴权使用的是Access Token的密钥(jwtConstants.secret),与Refresh Token的密钥分离,进一步隔离风险,确保即使Access Token的密钥泄露,也不会影响Refresh Token的安全性。

三、前端实现:401无感刷新与重试机制

前端的核心需求是“无感刷新”:当Access Token过期导致接口返回401时,自动使用Refresh Token换取新的双令牌,并重试原请求,整个过程用户无感知,不中断用户操作。本文基于Axios拦截器实现该逻辑,同时处理并发401的问题,避免重复刷新令牌。

1. 环境准备

确保前端项目中已安装Axios,同时引入全局状态管理工具(本文使用Zustand,也可使用Vuex、Redux等),用于存储用户信息和令牌:

# 安装Axios和Zustand
npm install axios zustand

2. Axios实例配置:请求拦截器自动携带Access Token

创建Axios实例(config.ts),在请求拦截器中,从localStorage(或全局状态)中获取Access Token,并自动写入请求头,确保所有业务请求都携带令牌:

// src/utils/config.ts
import axios from 'axios';
import { useUserStore } from '../store/userStore';

// 创建Axios实例
const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL, // 后端接口基础地址
  timeout: 5000,
  headers: {
    'Content-Type': 'application/json',
  },
});

// 请求拦截器:自动携带Access Token
api.interceptors.request.use(
  (config) => {
    const userStore = useUserStore();
    const token = userStore.token; // 从全局状态中获取Access Token(也可从localStorage获取)
    if (token) {
      // 按格式写入请求头:Authorization: Bearer <token>
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    // 请求错误处理
    return Promise.reject(error);
  },
);

export default api;

全局状态管理(userStore.ts)示例,用于存储用户信息、令牌,并提供更新令牌和登出的方法:

// src/store/userStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware'; // 可选:持久化存储

interface UserState {
  userInfo: any; // 用户信息
  token: string; // Access Token
  refreshToken: string; // Refresh Token
  // 设置令牌
  setTokens: (token: string, refreshToken: string) => void;
  // 设置用户信息
  setUserInfo: (userInfo: any) => void;
  // 登出:清空状态和本地存储
  logout: () => void;
}

export const useUserStore = create<UserState>(
  persist(
    (set) => ({
      userInfo: null,
      token: '',
      refreshToken: '',
      setTokens: (token, refreshToken) => set({ token, refreshToken }),
      setUserInfo: (userInfo) => set({ userInfo }),
      logout: () => {
        set({ userInfo: null, token: '', refreshToken: '' });
        // 清空localStorage(若使用persist,可省略)
        localStorage.removeItem('userStore');
      },
    }),
    {
      name: 'userStore', // 持久化存储的key
      getStorage: () => localStorage, // 存储到localStorage
    },
  ),
);

3. 响应拦截器:401无感刷新与重试

响应拦截器的核心逻辑是:当接口返回401(Unauthorized,Access Token过期)时,触发令牌刷新逻辑,并重试原请求。同时处理并发401问题,避免多个请求同时触发刷新,导致Refresh Token被重复使用而失效。

具体实现步骤:

  1. 判断接口返回状态是否为401,且原请求未标记过重试(避免无限重试)。
  2. 若当前没有正在进行的刷新操作,标记“正在刷新”,调用刷新接口换取新令牌。
  3. 若当前正在刷新,将原请求加入请求队列,等待刷新完成后统一重试。
  4. 刷新成功后,更新全局状态和localStorage中的令牌,重试原请求和队列中的请求。
  5. 刷新失败(如Refresh Token过期、无效),调用登出方法,清空状态并跳转登录页。
// src/utils/config.ts(补充响应拦截器)
import { useUserStore } from '../store/userStore';
import router from '../router'; // 路由实例,用于跳转登录页

// 定义请求队列:存储等待刷新令牌后重试的请求
let failedQueue: any[] = [];
// 定义刷新锁:标记是否正在刷新令牌
let isRefreshing = false;

// 响应拦截器:处理401无感刷新
api.interceptors.response.use(
  (response) => {
    // 正常响应,直接返回
    return response;
  },
  async (error) => {
    const originalRequest = error.config;
    const userStore = useUserStore();
    const { userId } = userStore.userInfo || {};
    const refreshToken = userStore.refreshToken;

    // 1. 判断是否为401错误,且未重试过(避免无限重试)
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true; // 标记为已重试

      // 2. 若没有正在刷新,触发刷新逻辑
      if (!isRefreshing) {
        isRefreshing = true; // 标记为正在刷新

        try {
          // 调用后端刷新接口,传入userId和refreshToken
          const response = await axios.post(`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`, {
            userId,
            refreshToken,
          });

          const { accessToken, refreshToken: newRefreshToken } = response.data;

          // 3. 刷新成功:更新全局状态和localStorage中的令牌
          userStore.setTokens(accessToken, newRefreshToken);

          // 4. 重试原请求:用新的Access Token替换原请求的请求头
          originalRequest.headers.Authorization = `Bearer ${accessToken}`;

          // 5. 重试队列中的所有请求
          processQueue(null, accessToken);

          // 6. 重试原请求并返回结果
          return api(originalRequest);
        } catch (err) {
          // 7. 刷新失败:登出并跳转登录页
          userStore.logout();
          router.push('/login');
          // 清空请求队列,返回错误
          processQueue(err, null);
          return Promise.reject(err);
        } finally {
          // 8. 刷新完成(无论成功失败),释放刷新锁
          isRefreshing = false;
        }
      } else {
        // 9. 若正在刷新,将原请求加入队列,等待刷新完成后重试
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject, originalRequest });
        });
      }
    }

    // 非401错误,直接返回错误
    return Promise.reject(error);
  },
);

// 处理请求队列:刷新成功后重试所有队列中的请求,失败则拒绝
function processQueue(error: any, token: string | null) {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      // 用新的Access Token替换请求头,并重试
      prom.originalRequest.headers.Authorization = `Bearer ${token}`;
      prom.resolve(api(prom.originalRequest));
    }
  });

  // 清空队列
  failedQueue = [];
}

关键细节说明:

  • 刷新锁(isRefreshing):避免多个并发请求同时触发刷新逻辑,导致Refresh Token被重复使用,进而被后端判定为异常请求,强制登出。
  • 请求队列(failedQueue):当多个请求同时遇到401时,除第一个请求触发刷新外,其余请求均加入队列,等待刷新完成后,用新的Access Token统一重试,确保所有请求都能正常响应。
  • 重试标记(_retry):给原请求添加_retry属性,标记为已重试,避免因刷新失败或其他原因导致无限重试,陷入死循环。
  • 全局状态联动:刷新成功后,通过userStore.setTokens更新全局状态中的令牌,同时同步到localStorage(若使用persist中间件);刷新失败时,调用logout方法清空状态,并跳转登录页,避免无效请求。

四、安全与登录态连续性小结

双令牌认证方案的核心价值的是“安全”与“体验”的平衡,通过后端的严格校验和前端的无感刷新,既保障了系统安全,又提升了用户体验。以下是方案的核心安全点和登录态连续性的实现总结:

核心要点实现方式安全/体验价值
密码安全注册/存储时使用bcrypt哈希处理,登录时用bcrypt.compare比对,不存储任何明文密码;盐值自动生成,相同密码哈希结果不同。防止密码泄露,避免彩虹表攻击,保障用户账号安全。
Token隔离Access Token与Refresh Token使用不同密钥、不同有效期;Access Token短效(15分钟),Refresh Token长效(7天)。降低Token泄露风险,Access Token泄露影响范围小,Refresh Token泄露有额外校验机制。
Refresh Token安全服务端存储Refresh Token的哈希值,刷新时比对哈希;每次刷新生成新的Refresh Token,实现令牌轮换,旧Token失效。防止数据库泄露导致Refresh Token被盗用;令牌轮换避免被盗用后持续生效。
无感刷新Axios响应拦截器捕获401错误,自动用Refresh Token换取新令牌,并重试原请求;并发401用队列和刷新锁控制,避免重复刷新。用户无需频繁登录,操作不中断,提升使用体验。
登出机制后端登出接口将用户refreshToken字段置空;前端登出时清空全局状态和localStorage,调用后端登出接口。确保登出后,Refresh Token无法再用于换取新令牌,彻底终止登录态。

在面试中,若能按照“双令牌设计思路→后端签发/存储/刷新逻辑→前端拦截器与重试机制→安全与连续性保障”这条线讲解,并重点突出“Refresh Token哈希存储、令牌轮换、并发401队列控制”这三个细节,能充分体现对双令牌方案的深入理解,展现自身的技术实力。

五、技术实现的关键细节与避坑指南

在实际开发中,双令牌方案的落地需要注意以下关键细节,否则可能出现安全隐患或体验问题,以下是常见的注意事项和避坑点:

1. Token存储位置:不同令牌,不同存储方式

Token的存储位置直接影响系统安全,尤其是Refresh Token,需严格按照安全规范存储,避免被XSS攻击窃取。

1.1 Access Token的存储

  • 常用存储方式:内存(JS变量)或localStorage。
  • 内存存储:仅存在当前页面的运行环境中,刷新或关闭页面后消失,安全性高(不易被XSS攻击窃取),但缺点是页面刷新后需要重新获取令牌(可通过持久化存储弥补)。
  • localStorage存储:持久化存储,页面刷新后仍存在,使用方便,但易受XSS攻击窃取。由于Access Token有效期仅15分钟,且配合CSP(内容安全策略)和严格的输入过滤,业界普遍认为这是可接受的风险权衡。
  • 避坑点:禁止将Access Token存储在sessionStorage中,sessionStorage仅在当前会话有效,且不同标签页不共享,会导致多标签页操作异常。

1.2 Refresh Token的存储(核心安全点)

Refresh Token必须存储在HttpOnly + Secure + SameSite=Strict的Cookie中,这是保障Refresh Token安全的关键,具体要求如下:

  • HttpOnly:禁止JavaScript读取Cookie,即使网站存在XSS漏洞,黑客也无法通过脚本窃取Refresh Token,这是最核心的安全配置。
  • Secure:仅在HTTPS协议下传输Cookie,避免HTTP协议下被窃听。
  • SameSite=Strict:仅当请求来自当前域名时,浏览器才会携带该Cookie,避免CSRF(跨站请求伪造)攻击。
  • path:设置为刷新接口的路径(如/api/refresh),仅访问该接口时才携带Cookie,减少暴露范围。
  • domain:设置为当前项目域名,仅当前域名生效,避免跨域名泄露。
  • maxAge:设置为7天(与Refresh Token的有效期一致),确保Cookie过期时间与令牌有效期同步。

后端设置Refresh Token到Cookie的示例代码(Node.js原生示例,NestJS中可通过res.cookie实现):

// 登录成功后,后端设置Refresh Token到Cookie中
app.post('/login', (req, res) => {
  const { userId } = req.user;
  // 生成Refresh Token(与NestJS中的getTokens方法逻辑一致)
  const refreshToken = generateRefreshToken(userId);

  // 设置Cookie,配置所有安全参数
  res.cookie('refresh_token', refreshToken, {
    httpOnly: true,        // 核心:禁止JS读取
    secure: true,          // 仅HTTPS下传输(开发环境可临时关闭,生产环境必须开启)
    sameSite: 'Strict',    // 仅同域名请求携带
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7天有效期,与Refresh Token一致
    path: '/api/refresh',  // 仅访问刷新接口时携带
    domain: 'your-app.com',// 项目域名,替换为实际域名
  });

  // 同时返回Access Token给前端(前端存储到localStorage/内存)
  const accessToken = generateAccessToken(userId);
  res.json({ access_token: accessToken, user_id: userId });
});

避坑点:绝对不能将Refresh Token存储在localStorage、sessionStorage或普通Cookie中,否则易被XSS攻击窃取,导致用户登录态被非法控制。

2. 前端拦截器逻辑:避免无限重试和死循环

前端拦截器的逻辑看似简单,但容易出现无限重试、死循环等问题,需注意以下几点:

  • 必须添加重试标记(_retry):给原请求添加_retry属性,标记为已重试,避免因刷新失败(如Refresh Token过期)导致请求反复触发401,陷入无限重试。
  • 刷新失败必须登出:当Refresh Token过期、无效或比对失败时,必须调用登出方法,清空本地状态并跳转登录页,避免持续发起无效的刷新请求,导致死循环。
  • 处理请求取消:若用户在刷新令牌期间跳转页面或取消请求,需取消正在进行的刷新请求和队列中的请求,避免不必要的接口调用。

3. 并发问题:避免Refresh Token重复使用导致登出

并发401是双令牌方案中最常见的问题,若处理不当,会导致用户被强制登出,具体场景和解决方案如下:

3.1 问题场景

当Access Token刚好过期时,用户同时发起两个或多个业务请求,这两个请求都会返回401,若没有刷新锁控制,会同时触发刷新逻辑,导致两个刷新请求同时携带同一个Refresh Token访问后端:

  1. 第一个刷新请求到达后端,校验Refresh Token有效,生成新的双令牌,并将新的Refresh Token哈希值写入数据库(旧的Refresh Token失效)。
  2. 第二个刷新请求到达后端,校验Refresh Token时,发现该Token对应的哈希值已被更新(旧的哈希值不存在),后端判定为异常请求(可能是Token被盗),直接作废该用户的所有Token,强制用户登出。

3.2 解决方案

通过“刷新锁(isRefreshing)+ 请求队列(failedQueue)”的方式,确保同一时间只有一个刷新请求,其余请求等待刷新完成后统一重试,具体逻辑已在前端响应拦截器中实现,核心要点:

  • 刷新锁(isRefreshing):标记当前是否正在进行刷新操作,若正在刷新,后续的401请求不触发新的刷新,而是加入队列。
  • 请求队列(failedQueue):存储等待刷新的请求,刷新成功后,用新的Access Token统一重试所有队列中的请求,避免重复刷新。

4. 其他安全细节

  • 密钥安全:JWT的密钥(access和refresh)需使用随机字符串,避免使用简单字符串(如123456),可通过Node.js的crypto模块生成随机密钥;密钥需存储在环境变量中,禁止硬编码在代码中,避免泄露。
  • CSP配置:前端配置内容安全策略(CSP),限制脚本加载来源,减少XSS攻击的风险,进一步保障Token安全。
  • Token过期提示:当Refresh Token过期,用户被跳转登录页时,可给出友好提示(如“登录已过期,请重新登录”),提升用户体验。
  • 异常监控:对401、403等异常状态进行监控,及时发现Token泄露、异常刷新等问题,便于快速排查。

六、总结

Access Token + Refresh Token双令牌认证方案,是前后端分离架构中最成熟、最常用的认证方案之一,它通过“短期访问令牌+长期刷新令牌”的分工协作,完美平衡了系统安全与用户体验。

本文从整体设计思路出发,详细拆解了基于NestJS后端和Axios前端的全流程实现,包括双令牌的签发与校验、密码的安全存储、前端无感刷新机制、并发问题处理以及关键安全细节。在实际开发中,可根据项目的具体需求,调整Token的有效期、存储方式和校验逻辑,例如:将Access Token的有效期调整为10分钟,Refresh Token的有效期调整为30天;对于高安全等级的项目,可增加短信验证、设备绑定等额外安全机制,进一步提升系统安全性。