在构建现代 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 前端请求流程
- 登录成功 → 后端设置
HttpOnlyCookie(含refresh_token),返回access_token。 - 后续请求 → Axios 在
Authorization: Bearer <access_token>中携带。 access_token过期(401)→ 前端调用/auth/refresh。- 后端从 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 策略。当请求到达受保护路由时:
- 从
AuthorizationHeader 提取access_token。 - 使用
JwtStrategy验证 Token 签名与有效期。 - 若有效,调用
validate()方法,返回用户信息并挂载到req.user。 - 若无效,抛出
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:
- 用户 A 登录 → 生成
rt_A并存 DB。 - 用户 B 在另一设备登录 → 生成
rt_B,覆盖 DB 中的refreshToken。 - 此时 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 生命周期、监控异常登录行为、及时更新依赖,才是长久之道。