从登录到鉴权,JWT 双 Token 机制完整实现思路

13 阅读3分钟

在前后端分离架构中,认证与授权是系统的安全命门。本文将带你深入 NestJS 如何构建安全的用户认证与鉴权系统,手把手实现 JWT 双 Token 机制。

🔐 登录功能:从密码存储到身份颁发

1. 注册:密码安全是第一道防线

密码存储绝不能是明文!我们使用 ​​bcrypt​​ 实现​​单向哈希加密​​:

// 注册时:对密码进行哈希处理
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
// 将 hashedPassword 存入数据库

​安全性设计​​:

  • ​防程序员​​:即使是开发者也无法从数据库看到真实密码
  • ​防黑客​​:即使数据库泄露,hash 值也无法反向推导出原密码
  • ​防彩虹表​​:bcrypt 的盐值机制让相同密码的 hash 也各不相同

​数据库序列修复技巧​​:

-- 解决 PostgreSQL 自增主键序列不同步问题
SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 0) FROM users));

2. 登录:Cookie 已死,JWT 当立

​传统方式的问题​​:

  • ​Cookie​​:HTTP 自动携带,但跨域问题复杂
  • ​localStorage​​:存储空间较大,但需手动管理

​现代方案​​:JWT(JSON Web Token)

// JWT 结构:Header.Payload.Signature
// Header: 算法类型
// Payload: 用户信息(可解密,不安全!重要信息勿放)
// Signature: 签名,验证 token 完整性

​axios 拦截器自动携带​​:

// 请求拦截器
axios.interceptors.request.use(config => {
  const token = localStorage.getItem('access_token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

🔄 JWT 双 Token 机制:安全与体验的平衡术

为什么需要双 Token?

​单 Token 的风险​​:

  • 一旦泄露,攻击者可长期冒充用户
  • 缩短过期时间会影响用户体验
  • 延长过期时间会增加安全风险

​双 Token 方案​​:

  • ​access_token​​:短时效(分钟级),用于 API 请求
  • ​refresh_token​​:长时效(天级),用于刷新 access_token

实现流程

// 1. 登录时生成双 token
async login(user: User) {
  const [access_token, refresh_token] = await Promise.all([
    this.jwtService.signAsync(
      { userId: user.id, username: user.username },
      { expiresIn: '15m' }
    ),
    this.jwtService.signAsync(
      { userId: user.id, type: 'refresh' },
      { expiresIn: '7d' }
    )
  ]);
  
  return { access_token, refresh_token };
}

// 2. 使用 Promise.all 优化性能
// 并发生成 token,减少总等待时间

​并发优化的其他应用场景​​:

// 文章列表查询:同时获取总数和列表
async getPosts(query: QueryDto) {
  const [total, list] = await Promise.all([
    this.postRepository.count(query),
    this.postRepository.find(query)
  ]);
  return { total, list };
}

🚨 错误异常处理:优雅地面对失败

HTTP 状态码语义化

// 3xx 重定向:资源位置变更
// 4xx 客户端错误:调用方问题
@Post('login')
async login(@Body() dto: LoginDto) {
  const user = await this.userService.findByUsername(dto.username);
  if (!user) {
    throw new NotFoundException('用户不存在'); // 404
  }
  
  const isValid = await bcrypt.compare(dto.password, user.password);
  if (!isValid) {
    throw new UnauthorizedException('密码错误'); // 401
  }
  
  if (user.isLocked) {
    throw new ForbiddenException('账户已被锁定'); // 403
  }
  
  return this.authService.login(user);
}

// 5xx 服务端错误:代码异常
@Get('profile')
async getProfile() {
  try {
    // 业务逻辑
  } catch (error) {
    throw new InternalServerErrorException('服务器内部错误'); // 500
  }
}

🛡️ 鉴权守卫:路由访问控制

使用 UseGuard 保护敏感操作

import { UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Controller('posts')
export class PostsController {
  @Post()
  @UseGuards(AuthGuard('jwt')) // 守卫在方法执行前拦截
  async createPost(@Body() dto: CreatePostDto, @Req() req) {
    // 守卫已验证 token 并将用户信息注入 req
    const userId = req.user.id;
    return this.postsService.create(dto, userId);
  }
}

解决 "Unknown authentication strategy 'jwt'" 错误

​问题根源​​:守卫找不到 jwt 策略配置

​解决方案​​:

// auth.module.ts
@Module({
  imports: [
    JwtModule.register({
      secret: process.env.JWT_SECRET,
      signOptions: { expiresIn: '15m' },
    }),
    PassportModule.register({ defaultStrategy: 'jwt' }), // 注册默认策略
  ],
  providers: [JwtStrategy], // 自定义策略
})
export class AuthModule {}

🔄 Refresh Token 刷新机制

刷新流程设计

// auth.controller.ts
@Post('refresh')
async refreshToken(@Body('refresh_token') refreshToken: string) {
  try {
    // 1. 验证 refresh_token
    const payload = this.jwtService.verify(refreshToken);
    if (payload.type !== 'refresh') {
      throw new UnauthorizedException('无效的刷新令牌');
    }
    
    // 2. 查询用户是否存在
    const user = await this.userService.findById(payload.userId);
    if (!user) {
      throw new UnauthorizedException('用户不存在');
    }
    
    // 3. 生成新的 token 对
    const [newAccessToken, newRefreshToken] = await Promise.all([
      this.jwtService.signAsync(
        { userId: user.id, username: user.username },
        { expiresIn: '15m' }
      ),
      this.jwtService.signAsync(
        { userId: user.id, type: 'refresh' },
        { expiresIn: '7d' }
      ),
    ]);
    
    return {
      access_token: newAccessToken,
      refresh_token: newRefreshToken,
    };
  } catch (error) {
    throw new UnauthorizedException('刷新令牌无效或已过期');
  }
}

axios 响应拦截器自动刷新

// axios 响应拦截器
axios.interceptors.response.use(
  response => response, // 成功直接返回
  async error => {
    const originalRequest = error.config;
    
    // 如果是 401 错误且不是刷新请求
    if (error.response?.status === 401 && 
        !originalRequest._retry && 
        !originalRequest.url.includes('refresh')) {
      
      originalRequest._retry = true;
      
      try {
        // 使用 refresh_token 获取新 token
        const refreshToken = localStorage.getItem('refresh_token');
        const { data } = await axios.post('/auth/refresh', { refresh_token: refreshToken });
        
        // 更新本地存储
        localStorage.setItem('access_token', data.access_token);
        localStorage.setItem('refresh_token', data.refresh_token);
        
        // 更新原请求的 Authorization 头
        originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
        
        // 重新发起原请求
        return axios(originalRequest);
      } catch (refreshError) {
        // 刷新失败,跳转到登录页
        localStorage.clear();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }
    
    return Promise.reject(error);
  }
);

🎯 关键设计要点

1. 安全性考虑

  • access_token 短时效降低泄露风险
  • refresh_token 仅用于刷新,不用于 API 请求
  • 每次刷新生成新的 token 对,旧的立即失效

2. 用户体验优化

  • 静默刷新,用户无感知
  • 7 天 refresh_token 有效期,减少频繁登录
  • 统一的错误处理,友好的提示信息

3. 性能优化

  • Promise.all 并发处理减少等待时间
  • JWT 无状态验证,减少数据库查询
  • Redis 可存储 token 黑名单(进阶)

4. 可维护性

  • 统一的异常过滤器
  • 模块化的守卫设计
  • 清晰的错误码和日志

💡 最佳实践建议

  1. ​生产环境​​:使用更强的 JWT 密钥,定期轮换
  2. ​敏感操作​​:增加二次验证(如短信验证码)
  3. ​监控​​:记录登录失败次数,防止暴力破解
  4. ​多端登录​​:记录设备信息,支持单设备登录
  5. ​退出登录​​:服务端维护 token 黑名单

总结

通过 NestJS 实现完整的 JWT 双 Token 认证系统,不仅提升了安全性,也优化了用户体验。关键在于理解各个模块的职责划分和交互流程,从密码的哈希存储,到 token 的生成验证,再到守卫的鉴权拦截,最后到客户端的自动刷新,形成一个完整的闭环。

记住:​​没有绝对的安全,只有不断演进的安全策略​​。随着业务发展,可以在此基础上增加更复杂的风控策略,如异地登录提醒、新设备验证等,构建更加坚固的安全体系。