NestJS 用户认证系统深度解析:从密码加密到双 Token 鉴权

3 阅读6分钟

在构建现代 Web 应用时,用户身份认证(Authentication)与权限控制(Authorization)是保障系统安全的核心环节。NestJS 凭借其模块化架构与对 Passport.js 的深度集成,为开发者提供了强大而灵活的认证解决方案。本文将带你从零搭建一个生产级的用户认证体系,并深入剖析每个环节的技术细节、安全考量与最佳实践。


一、注册阶段:密码必须“不可逆”

1.1 为什么不能明文存储密码?

一旦数据库泄露,明文密码将直接暴露用户隐私,甚至被用于撞库攻击(Credential Stuffing)。因此,密码必须经过单向哈希处理,且该过程不可逆。

1.2 为何选择 bcrypt?

  • 自适应慢速算法:通过 salt rounds 控制计算复杂度(默认 10~12 轮),有效抵御暴力破解。
  • 内置随机盐值(Salt) :每次哈希结果唯一,防止彩虹表攻击。
  • 行业标准:被 OWASP 推荐为密码存储首选方案。

1.3 实现示例(NestJS Service)

// users.service.ts
import * as bcrypt from 'bcrypt';

async hashPassword(password: string): Promise<string> {
  const salt = await bcrypt.genSalt(12); // 安全强度:12 轮
  return bcrypt.hash(password, salt);
}

async validatePassword(plain: string, hashed: string): Promise<boolean> {
  return bcrypt.compare(plain, hashed);
}

✅ 安全提示:永远不要在日志、响应或错误信息中打印原始密码!


二、登录与 Token 机制:单 Token vs 双 Token

2.1 单 Token 的致命缺陷

传统 JWT 登录仅返回一个 access_token,有效期较长(如 1 小时)。若该 Token 被中间人截获(如 XSS、网络嗅探),攻击者可在有效期内完全冒充用户,且无法主动使其失效(除非引入 Token 黑名单,增加复杂度)。

2.2 双 Token 机制:安全与体验的平衡

Token 类型作用有效期存储位置安全要求
access_token访问受保护 API短(5~15 分钟)内存 / localStorage可被窃取,但很快过期
refresh_token换取新的 access_token长(7 天)HttpOnly Cookie必须防 XSS

🔒 关键设计refresh_token 不应出现在前端 JavaScript 可访问的存储中(如 localStorage),而应通过 HttpOnly + Secure Cookie 传输,彻底杜绝 XSS 窃取风险。

2.3 NestJS 中生成双 Token

使用 @nestjs/jwt 并发生成两个 Token:

// auth.service.ts
async login(user: User) {
  const payload = { sub: user.id, username: user.username };

  // 并行生成,提升性能
  const [accessToken, refreshToken] = await Promise.all([
    this.jwtService.signAsync(payload, { expiresIn: '15m' }),
    this.jwtService.signAsync(payload, { expiresIn: '7d' })
  ]);

  // 将 refresh_token 存入数据库(用于后续验证/吊销)
  await this.updateRefreshToken(user.id, refreshToken);

  return {
    accessToken,
    // 注意:refreshToken 不返回给前端!而是通过 Set-Cookie
  };
}

⚠️ 重要refresh_token 应持久化到数据库(或 Redis),并与用户 ID 绑定。这样可在用户登出时主动清除,实现“立即失效”。

2.4 前端请求流程

  1. 登录成功 → 后端设置 HttpOnly Cookie(含 refresh_token),返回 access_token
  2. 后续请求 → Axios 在 Authorization: Bearer <access_token> 中携带。
  3. access_token 过期(401)→ 前端调用 /auth/refresh
  4. 后端从 Cookie 读取 refresh_token,验证有效性 → 返回新 access_token

三、刷新 Token 接口:独立鉴权逻辑

/auth/refresh 不能使用 AuthGuard('jwt') ,因为此时 access_token 已失效。需手动验证 refresh_token

// auth.controller.ts
@Post('refresh')
async refresh(@Req() req: Request) {
  const refreshToken = req.cookies['refresh_token'];
  if (!refreshToken) throw new UnauthorizedException();

  try {
    const payload = await this.jwtService.verifyAsync(refreshToken, {
      secret: process.env.JWT_REFRESH_SECRET,
    });

    // 验证数据库中是否存在匹配的 refresh_token
    const user = await this.usersService.findUserById(payload.sub);
    if (user.refreshToken !== refreshToken) 
      throw new UnauthorizedException('Invalid refresh token');

    // 生成新 access_token(可选:同时轮换 refresh_token)
    const newAccessToken = await this.jwtService.signAsync(
      { sub: user.id, username: user.username },
      { expiresIn: '15m' }
    );

    return { accessToken: newAccessToken };
  } catch {
    throw new UnauthorizedException();
  }
}

🔁 进阶优化:采用 Refresh Token Rotation(每次刷新都生成新 refresh_token 并使旧的失效),进一步提升安全性。


四、鉴权守卫(AuthGuard):保护核心接口

4.1 守卫如何工作?

NestJS 的 AuthGuard 基于 Passport.js 策略。当请求到达受保护路由时:

  1. 从 Authorization Header 提取 access_token
  2. 使用 JwtStrategy 验证 Token 签名与有效期。
  3. 若有效,调用 validate() 方法,返回用户信息并挂载到 req.user
  4. 若无效,抛出 401 Unauthorized

4.2 自定义 JwtStrategy

// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private usersService: UsersService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_ACCESS_SECRET,
    });
  }

  async validate(payload: JwtPayload) {
    // 可在此处查询数据库确认用户状态(如是否被封禁)
    const user = await this.usersService.findUserById(payload.sub);
    if (!user) throw new UnauthorizedException();
    return user; // 挂载到 req.user
  }
}

4.3 应用守卫

// posts.controller.ts
@UseGuards(AuthGuard('jwt'))
@Post()
createPost(@Body() dto: CreatePostDto, @Req() req) {
  // req.user 即为 JwtStrategy.validate() 返回的用户对象
  return this.postsService.create(dto, req.user.id);
}

❗ 常见错误Unknown authentication strategy "jwt"
原因:未在 AuthModule 中提供 JwtStrategy
解决

// users.service.ts
import * as bcrypt from 'bcrypt';

async hashPassword(password: string): Promise<string> {
  const salt = await bcrypt.genSalt(12); // 安全强度:12 轮
  return bcrypt.hash(password, salt);
}

async validatePassword(plain: string, hashed: string): Promise<boolean> {
  return bcrypt.compare(plain, hashed);
}

五、异常处理:统一错误响应

NestJS 提供一系列内置异常类,确保错误响应标准化:

> // auth.module.ts
> providers: [AuthService, JwtStrategy],
> imports: [JwtModule.register({})]
> 

可配合全局异常过滤器统一格式:

// 示例:登录失败
if (!user || !(await this.validatePassword(password, user.password))) {
  throw new UnauthorizedException('Invalid email or password');
}

// 示例:权限不足
if (post.authorId !== userId) {
  throw new ForbiddenException('You cannot edit this post');
}

六、高频面试问题与深度解答

Q1:为什么 refresh_token 要存数据库?不能只靠 JWT 本身吗?

A:JWT 本身无法主动失效。若仅依赖签名验证,即使用户登出,被盗的 refresh_token 仍可继续使用。将其存入数据库后,可在以下场景使其失效:

  • 用户主动登出
  • 修改密码
  • 检测到异常登录
  • 强制所有设备下线

Q2:HttpOnly Cookie 能防 XSS,但 CSRF 怎么办?

A:确实,Cookie 会自动携带,存在 CSRF 风险。解决方案:

  • 对敏感操作(如转账、删号)要求二次验证(短信/密码)
  • 使用 SameSite=Strict Cookie 属性(现代浏览器支持)
  • 引入 CSRF Token(前端从 API 获取,提交时校验)

Q3:双 Token 机制下,如何实现“踢下线”功能?

A

  1. 用户 A 登录 → 生成 rt_A 并存 DB。
  2. 用户 B 在另一设备登录 → 生成 rt_B,覆盖 DB 中的 refreshToken
  3. 此时 A 的 rt_A 与 DB 不匹配 → 下次刷新时失败,强制重新登录。

Q4:为什么不用 localStorage 存 access_token?

A:localStorage 可被 XSS 脚本读取。虽然 access_token 短期有效,但攻击者仍可利用窗口期发起请求。更安全的做法:

  • access_token 存内存(如 Vuex/Pinia state)
  • 页面刷新后通过 refresh_token(HttpOnly Cookie)静默获取新 token

七、总结:生产环境 Checklist

✅ 密码使用 bcrypt(salt rounds ≥ 12)
✅ access_token 短期有效(≤15 分钟)
✅ refresh_token 存数据库 + HttpOnly Cookie
✅ 敏感接口使用 AuthGuard('jwt')
✅ 实现 /auth/refresh 独立验证逻辑
✅ 全局异常过滤器统一错误格式
✅ 登出时清除数据库中的 refresh_token
✅ 考虑 Refresh Token Rotation 与设备管理


通过以上设计,你将构建一个兼顾安全性、用户体验与可维护性的认证系统。NestJS 的模块化与装饰器语法让这一切变得清晰而优雅——这正是它成为企业级 Node.js 框架首选的原因之一。

📌 最后建议:安全无小事。定期审计 Token 生命周期、监控异常登录行为、及时更新依赖,才是长久之道。