在前后端分离架构中,认证与授权是系统的安全命门。本文将带你深入 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. 可维护性
- 统一的异常过滤器
- 模块化的守卫设计
- 清晰的错误码和日志
💡 最佳实践建议
- 生产环境:使用更强的 JWT 密钥,定期轮换
- 敏感操作:增加二次验证(如短信验证码)
- 监控:记录登录失败次数,防止暴力破解
- 多端登录:记录设备信息,支持单设备登录
- 退出登录:服务端维护 token 黑名单
总结
通过 NestJS 实现完整的 JWT 双 Token 认证系统,不仅提升了安全性,也优化了用户体验。关键在于理解各个模块的职责划分和交互流程,从密码的哈希存储,到 token 的生成验证,再到守卫的鉴权拦截,最后到客户端的自动刷新,形成一个完整的闭环。
记住:没有绝对的安全,只有不断演进的安全策略。随着业务发展,可以在此基础上增加更复杂的风控策略,如异地登录提醒、新设备验证等,构建更加坚固的安全体系。