项目:music-api(音乐 App 后端)
适用场景:管理后台 (Admin) + Flutter App 双端认证
技术栈:NestJS 11 + TypeORM + Passport + JWT + bcrypt + nanoid
版本:TypeScript 5.7+
目录
- 项目背景与前提
- 整体架构设计
- 环境准备
- [DTO 层:请求的守门员](#DTO 层:请求的守门员 "#4-dto-%E5%B1%82%E8%AF%B7%E6%B1%82%E7%9A%84%E5%AE%88%E9%97%A8%E5%91%98")
- [Passport 策略:认证的引擎](#Passport 策略:认证的引擎 "#5-passport-%E7%AD%96%E7%95%A5%E8%AE%A4%E8%AF%81%E7%9A%84%E5%BC%95%E6%93%8E")
- [Guard 与装饰器:权限的关卡](#Guard 与装饰器:权限的关卡 "#6-guard-%E4%B8%8E%E8%A3%85%E9%A5%B0%E5%99%A8%E6%9D%83%E9%99%90%E7%9A%84%E5%85%B3%E5%8D%A1")
- AuthService:业务逻辑的核心
- AuthController:对外暴露的接口
- 模块组装:让一切运转起来
- 接口测试与调试
- 安全设计深度解析
- 附录:扩展与踩坑
1. 项目背景与前提
1.1 项目概况
这是一个音乐类 App 的后端服务(sogo-api),采用 NestJS 11 构建,需要同时服务两个客户端:
- 管理后台:供运营人员、管理员使用,管理用户、内容、审核艺人入驻等
- Flutter App:供普通用户和艺人使用,听音乐、发布作品、社交互动
两个客户端的安全需求和用户体验完全不同,因此需要设计双认证体系。
1.2 核心依赖版本
json
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.4",
"@nestjs/typeorm": "^11.0.1",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dayjs": "^1.11.21",
"mysql2": "^3.22.5",
"nanoid": "^5.1.11",
"nest-winston": "^1.10.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1",
"typeorm": "^1.0.0",
"typeorm-naming-strategies": "^4.1.0",
"uuid": "^14.0.0",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.0",
"dotenv": "^17.4.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
1.3 User 实体类
在编写认证逻辑之前,先了解用户实体类的结构至关重要。认证系统的所有校验逻辑(角色、状态、封禁等)都依赖这个实体。
typescript
// src/modules/user/entities/user.entity.ts
import { nanoid } from "nanoid";
import {
BeforeInsert,
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from "typeorm";
// ==================== 枚举定义 ====================
export enum UserStatus {
ACTIVE = "active", // 正常
INACTIVE = "inactive", // 未激活(如邮箱未验证)
BANNED = "banned", // 封禁
PENDING = "pending", // 待审核(艺人入驻等)
DELETED = "deleted", // 已注销(软删除)
}
export enum UserRole {
USER = "user", // 普通用户
ADMIN = "admin", // 管理员
SUPER_ADMIN = "super_admin", // 超级管理员
ARTIST = "artist", // 艺人
MODERATOR = "moderator", // 社区运营/版主
}
export enum AuthProvider {
LOCAL = "local", // 手机号/邮箱+密码
WECHAT = "wechat", // 微信
GOOGLE = "google", // Google
APPLE = "apple", // Apple
}
// ==================== 实体定义 ====================
@Entity("users")
@Index(["phone"], { unique: true, where: "phone IS NOT NULL" })
@Index(["email"], { unique: true, where: "email IS NOT NULL" })
export class User {
@PrimaryColumn({ type: "varchar", length: 12 })
userId!: string;
@BeforeInsert()
generateId() {
this.userId = `u_${nanoid(10)}`; // 加前缀,便于日志排查
}
// ==================== 认证相关 ====================
@Column({ type: "varchar", length: 20, nullable: true })
phone: string | null = null;
@Column({ type: "varchar", length: 100, nullable: true })
email: string | null = null;
@Column({ type: "varchar", length: 255, nullable: true })
passwordHash: string | null = null;
@Column({ type: "enum", enum: AuthProvider, default: AuthProvider.LOCAL })
provider!: AuthProvider;
@Column({ type: "varchar", nullable: true })
openId: string | null = null;
// ==================== 角色与状态 ====================
@Column({ type: "enum", enum: UserRole, default: UserRole.USER })
role!: UserRole;
@Column({ type: "enum", enum: UserStatus, default: UserStatus.ACTIVE })
status!: UserStatus;
@Column({ type: "timestamp", nullable: true })
banExpiresAt: Date | null = null; // 封禁截止时间,null 表示永久封禁
@Column({ type: "varchar", nullable: true })
banReason: string | null = null; // 封禁原因
// ==================== 用户资料 ====================
@Column({ type: "varchar", length: 50, default: "" })
displayName!: string;
@Column({ type: "varchar", nullable: true })
avatar: string | null = null;
@Column({ type: "varchar", length: 500, nullable: true })
bio: string | null = null;
@Column({ type: "date", nullable: true })
birthday: Date | null = null;
@Column({ type: "tinyint", nullable: true })
gender: number | null = null; // 0女 1男 2保密
// ==================== 审计与安全 ====================
@Column({ type: "varchar", length: 50, nullable: true })
lastLoginIp: string | null = null;
@Column({ type: "timestamp", nullable: true })
lastLoginAt: Date | null = null;
@Column({ type: "int", default: 0 })
failedLoginAttempts!: number; // 登录失败次数(防暴力破解)
@Column({ type: "timestamp", nullable: true })
passwordChangedAt: Date | null = null;
// ==================== 时间戳 ====================
@CreateDateColumn({ type: "timestamp" })
createdAt!: Date;
@UpdateDateColumn({ type: "timestamp" })
updatedAt!: Date;
@DeleteDateColumn({ type: "timestamp", nullable: true })
deletedAt: Date | null = null; // 软删除
// ==================== 便捷 getter ====================
get isAdmin(): boolean {
return this.role === UserRole.ADMIN || this.role === UserRole.SUPER_ADMIN;
}
get isSuperAdmin(): boolean {
return this.role === UserRole.SUPER_ADMIN;
}
get isBanned(): boolean {
if (this.status !== UserStatus.BANNED) return false;
if (!this.banExpiresAt) return true; // 永久封禁
return new Date() < this.banExpiresAt; // 临时封禁:检查是否过期
}
get isAvailable(): boolean {
return (
this.status === UserStatus.ACTIVE && !this.isBanned && !this.deletedAt
);
}
}
实体设计要点:
-
主键用 nanoid :
u_${nanoid(10)}生成 12 位字符串 ID(如u_a1b2c3d4e5),比自增 ID 更安全(无法被遍历),比 UUID 更短(适合 URL 和日志)。 -
phone 和 email 可空 :第三方登录用户可能没有手机号或邮箱,因此设为
nullable: true,同时用where: "phone IS NOT NULL"保证非空时的唯一性。 -
passwordHash 可空:第三方登录用户没有密码,这是正常的。
-
三个 getter 是认证的核心依赖:
isAdmin:判断是否为管理员(ADMIN 或 SUPER_ADMIN)isBanned:判断封禁状态(支持永久封禁和临时封禁)isAvailable:综合判断用户是否可用(活跃 + 未封禁 + 未删除)
-
软删除 :
@DeleteDateColumn让 TypeORM 自动处理软删除,deletedAt不为 null 即表示已删除。
1.4 main.ts 入口文件
typescript
// src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ResponseInterceptor } from "./common/interceptor/response.interceptor";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { QueryErrorFilter } from "./common/filters/query-error.filter";
import { ValidationPipe } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { loggerConfig } from "./config/logger.config";
import { WinstonModule } from "nest-winston";
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: WinstonModule.createLogger(loggerConfig),
});
app.enableCors("*");
app.setGlobalPrefix("api");
// 异常过滤器:TypeORM 错误优先,HTTP 异常兜底
app.useGlobalFilters(
new QueryErrorFilter(),
new HttpExceptionFilter()
);
app.useGlobalInterceptors(new ResponseInterceptor());
// 全局参数校验管道
app.useGlobalPipes(new ValidationPipe());
// Swagger API 文档
const config = new DocumentBuilder()
.setTitle("API Documentation")
.setVersion("1.0")
.addBearerAuth() // 启用 Bearer Token 认证
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("docs", app, document);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap().catch((error) => {
console.error("Application failed to start:", error);
});
main.ts 中与认证相关的配置:
app.setGlobalPrefix("api"):所有接口前缀为/api,如登录接口实际是POST /api/auth/admin/loginnew ValidationPipe():全局启用 DTO 校验,所有@Body()参数会自动根据 DTO 的class-validator装饰器进行校验.addBearerAuth():Swagger 文档中启用 Bearer Token 认证,测试需要登录的接口时可以直接输入 Token
1.5 当前 AppModule 的问题
typescript
// src/app.module.ts(当前版本)
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { TypeOrmModule } from "@nestjs/typeorm";
import { UserModule } from "./modules/user/user.module";
import { AuthModule } from "./modules/auth/auth.module";
import dbConfig from "./config/db.config";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
useFactory: async () => dbConfig(),
}),
// ⚠️ 问题:全局 JwtModule 注册,所有模块共用同一个配置
JwtModule.register({
secret: "lxxw223",
signOptions: { expiresIn: "120s" },
}),
UserModule,
AuthModule,
],
})
export class AppModule {}
存在的问题:
- 硬编码密钥 :
secret: "lxxw223"应该来自环境变量 - 单一有效期 :
expiresIn: "120s"对所有 Token 生效,无法区分 Admin 和 App 的不同有效期需求 - 全局注册 :
JwtModule.register在 AppModule 中全局注册,AuthModule 不需要再注册,但失去了灵活性
解决方案 :将 JwtModule 从 AppModule 移除,改为在 AuthModule 中使用 registerAsync 动态配置,支持不同的密钥和有效期策略。
2. 整体架构设计
2.1 为什么需要双认证体系?
管理后台和 Flutter App 的安全需求完全不同:
| 维度 | 管理后台 (Admin) | Flutter App |
|---|---|---|
| 认证方式 | 邮箱/密码 + 可选验证码 | 手机号/密码 或 第三方登录 |
| Token 有效期 | Access: 2h / Refresh: 7d | Access: 7d / Refresh: 30d |
| 安全等级 | 高(MFA/验证码) | 中(设备绑定) |
| 角色校验 | 严格(仅 ADMIN/SUPER_ADMIN) | 宽松(USER/ARTIST 均可) |
| 登录隔离 | 管理员无法通过 App 登录 | 普通用户无法登录后台 |
核心思想:不是"两套系统",而是"同一套内核,两条通道"。底层共用用户表、共用密码哈希、共用 JWT 签发逻辑,但上层根据客户端类型走不同的校验流程、生成不同策略的 Token。
2.2 认证流程
scss
客户端请求
│
▼
┌─────────────────┐
│ Controller │ ← DTO 校验参数格式
│ (auth.controller)│
└────────┬────────┘
│
▼
┌─────────────────┐
│ Service │ ← 业务逻辑:验证用户、生成 Token
│ (auth.service) │
└────────┬────────┘
│
┌────┴────┐
│ │
▼ ▼
┌────────┐ ┌────────┐
│ validate│ │generate│
│ Local │ │ Tokens │
│ User │ │ │
└────────┘ └────────┘
│ │
└────┬────┘
│
▼
┌─────────────────┐
│ 返回 Token │
│ + 用户信息 │
└─────────────────┘
2.3 项目目录结构
ruby
src/
├── modules/
│ ├── auth/ # 认证模块(本文核心)
│ │ ├── auth.controller.ts # HTTP 接口
│ │ ├── auth.service.ts # 业务逻辑
│ │ ├── auth.module.ts # 模块组装
│ │ ├── dto/ # 请求参数校验
│ │ │ ├── admin-login.dto.ts
│ │ │ ├── app-login.dto.ts
│ │ │ ├── app-register.dto.ts
│ │ │ └── refresh-token.dto.ts
│ │ ├── guards/ # 路由守卫
│ │ │ ├── jwt-auth.guard.ts
│ │ │ ├── roles.guard.ts
│ │ │ └── admin.guard.ts
│ │ ├── strategies/ # Passport 策略
│ │ │ ├── jwt.strategy.ts
│ │ │ └── local.strategy.ts
│ │ └── decorators/ # 自定义装饰器
│ │ ├── roles.decorator.ts
│ │ └── current-user.decorator.ts
│ └── user/ # 用户模块(已存在)
│ ├── user.service.ts
│ ├── user.module.ts
│ └── entities/user.entity.ts
├── common/
│ └── enums/
│ └── auth-type.enum.ts
├── app.module.ts
└── main.ts
3. 环境准备
3.1 安装依赖
bash
npm install @nestjs/passport @nestjs/jwt passport passport-local passport-jwt bcrypt
npm install -D @types/passport-jwt @types/passport-local @types/bcrypt
3.2 环境变量配置(.env)
bash
# JWT 签名密钥(生产环境务必使用强随机字符串)
JWT_SECRET=lxxw223
JWT_REFRESH_SECRET=refresh_lxxw223
# 管理后台 Token 有效期
JWT_ADMIN_EXPIRES=2h
JWT_ADMIN_REFRESH=7d
# App Token 有效期
JWT_APP_EXPIRES=7d
JWT_APP_REFRESH=30d
# bcrypt 盐轮数
BCRYPT_SALT_ROUNDS=12
关于密钥 :JWT_SECRET 和 JWT_REFRESH_SECRET 必须是不同的值。如果相同,攻击者拿到 Refresh Token 后可以直接当作 Access Token 使用,绕过短有效期限制。
4. DTO 层:请求的守门员
DTO(Data Transfer Object)配合 class-validator 和 class-transformer,在请求到达 Controller 之前就完成参数校验。
4.1 管理后台登录 DTO
typescript
// src/modules/auth/dto/admin-login.dto.ts
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AdminLoginDto {
@ApiProperty({ description: '管理员邮箱' })
@IsEmail({}, { message: '请输入有效的邮箱地址' })
email!: string;
@ApiProperty({ description: '密码' })
@IsString()
@MinLength(6, { message: '密码至少6位' })
password!: string;
@ApiProperty({ description: '验证码(生产环境建议开启)', required: false })
@IsOptional()
@IsString()
captcha?: string;
}
4.2 App 登录 DTO
typescript
// src/modules/auth/dto/app-login.dto.ts
import { IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AppLoginDto {
@ApiProperty({ description: '手机号', example: '13800138000' })
@IsString()
@MinLength(11, { message: '手机号格式不正确' })
phone!: string;
@ApiProperty({ description: '密码' })
@IsString()
@MinLength(6, { message: '密码至少6位' })
password!: string;
@ApiProperty({ description: '设备ID,用于设备绑定和安全校验', required: false })
@IsOptional()
@IsString()
deviceId?: string;
}
4.3 App 注册 DTO
typescript
// src/modules/auth/dto/app-register.dto.ts
import { IsString, MinLength, IsOptional, IsEmail, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AppRegisterDto {
@ApiProperty({ description: '手机号' })
@IsString()
@MinLength(11)
phone!: string;
@ApiProperty({ description: '密码' })
@IsString()
@MinLength(6)
@Matches(/^(?=.*[a-zA-Z])(?=.*\d).{6,}$/, {
message: '密码必须包含字母和数字'
})
password!: string;
@ApiProperty({ description: '短信验证码' })
@IsString()
@MinLength(4)
smsCode!: string;
@ApiProperty({ description: '昵称', required: false })
@IsOptional()
@IsString()
@MinLength(2)
displayName?: string;
@ApiProperty({ description: '邮箱', required: false })
@IsOptional()
@IsEmail()
email?: string;
@ApiProperty({ description: '设备ID', required: false })
@IsOptional()
@IsString()
deviceId?: string;
}
4.4 刷新令牌 DTO
typescript
// src/modules/auth/dto/refresh-token.dto.ts
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty({ description: '刷新令牌' })
@IsString()
refreshToken!: string;
}
5. Passport 策略:认证的引擎
5.1 JWT 策略
typescript
// src/modules/auth/strategies/jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { UserService } from '../../user/user.service';
import { UserStatus } from '../../user/entities/user.entity';
interface JwtPayload {
sub: string;
type: string;
role: string;
iat: number;
exp: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private configService: ConfigService,
private userService: UserService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET', 'lxxw223'),
passReqToCallback: true,
});
}
async validate(req: Request, payload: JwtPayload) {
// 每次请求都查询数据库,确保用户状态实时生效
const user = await this.userService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('用户不存在');
}
if (user.status === UserStatus.DELETED) {
throw new UnauthorizedException('账户已注销');
}
if (user.isBanned) {
throw new UnauthorizedException('账户已被封禁');
}
return user;
}
}
关键设计:
passReqToCallback: true:将 Request 对象传给validate,后续可做设备绑定校验- 每次请求查数据库:用户被封禁后,已有 Token 立即失效,不需要等待过期
- 返回完整 User 对象 :Guard 可以直接用
user.role判断权限,无需再查库
5.2 本地策略
typescript
// src/modules/auth/strategies/local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
constructor(private authService: AuthService) {
super({ usernameField: 'account' });
}
async validate(account: string, password: string): Promise<any> {
const user = await this.authService.validateLocalUser(account, password);
if (!user) {
throw new UnauthorizedException('账号或密码错误');
}
if (!user.isAvailable) {
throw new UnauthorizedException('账户状态异常');
}
return user;
}
}
6. Guard 与装饰器:权限的关卡
6.1 JWT 认证 Guard
typescript
// src/modules/auth/guards/jwt-auth.guard.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('无效的认证令牌');
}
return user;
}
}
6.2 角色权限 Guard
typescript
// src/modules/auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { UserRole } from '../../user/entities/user.entity';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) {
throw new ForbiddenException('未登录');
}
const hasRole = requiredRoles.some((role) => user.role === role);
if (!hasRole) {
throw new ForbiddenException('权限不足,无法访问该资源');
}
return true;
}
}
6.3 管理员专属 Guard
typescript
// src/modules/auth/guards/admin.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { UserRole, UserStatus } from '../../user/entities/user.entity';
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException('未登录');
}
if (user.role !== UserRole.ADMIN && user.role !== UserRole.SUPER_ADMIN) {
throw new ForbiddenException('仅管理员可访问');
}
if (user.status !== UserStatus.ACTIVE) {
throw new ForbiddenException('账户状态异常,无法登录管理后台');
}
if (user.isBanned) {
throw new ForbiddenException('账户已被封禁');
}
return true;
}
}
6.4 装饰器
typescript
// src/modules/auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../user/entities/user.entity';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
typescript
// src/modules/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { User } from '../../user/entities/user.entity';
export const CurrentUser = createParamDecorator(
(data: keyof User | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as User;
return data ? user?.[data] : user;
},
);
7. AuthService:业务逻辑的核心
typescript
// src/modules/auth/auth.service.ts
import { Injectable, UnauthorizedException, BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User, UserRole, UserStatus, AuthProvider } from '../user/entities/user.entity';
import { UserService } from '../user/user.service';
import { AdminLoginDto } from './dto/admin-login.dto';
import { AppLoginDto } from './dto/app-login.dto';
import { AppRegisterDto } from './dto/app-register.dto';
interface TokenPayload {
sub: string;
type: 'admin' | 'app';
role: UserRole;
deviceId?: string;
}
interface AuthResponse {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: {
userId: string;
role: UserRole;
displayName: string;
avatar: string | null;
status: UserStatus;
};
}
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
private userService: UserService,
private jwtService: JwtService,
private configService: ConfigService,
) {}
// ==================== 通用方法 ====================
private async generateTokens(payload: TokenPayload): Promise<{
accessToken: string;
refreshToken: string;
expiresIn: number
}> {
const accessExpiresIn = payload.type === 'admin'
? this.configService.get('JWT_ADMIN_EXPIRES', '2h')
: this.configService.get('JWT_APP_EXPIRES', '7d');
const refreshExpiresIn = payload.type === 'admin'
? this.configService.get('JWT_ADMIN_REFRESH', '7d')
: this.configService.get('JWT_APP_REFRESH', '30d');
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
expiresIn: accessExpiresIn,
secret: this.configService.get('JWT_SECRET', 'lxxw223'),
}),
this.jwtService.signAsync(
{ ...payload, tokenType: 'refresh' },
{
expiresIn: refreshExpiresIn,
secret: this.configService.get('JWT_REFRESH_SECRET', 'refresh_lxxw223'),
},
),
]);
return {
accessToken,
refreshToken,
expiresIn: this.parseExpiresToSeconds(accessExpiresIn),
};
}
private parseExpiresToSeconds(expires: string): number {
const match = expires.match(/^(\d+)([smhd])$/);
if (!match) return 7200;
const [, num, unit] = match;
const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
return parseInt(num) * multipliers[unit as keyof typeof multipliers];
}
// ==================== 本地验证 ====================
async validateLocalUser(account: string, password: string): Promise<User | null> {
const isEmail = account.includes('@');
const user = await this.userRepository.findOne({
where: isEmail ? { email: account } : { phone: account },
});
if (!user || !user.passwordHash) {
return null;
}
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
user.failedLoginAttempts += 1;
await this.userRepository.save(user);
return null;
}
user.failedLoginAttempts = 0;
user.lastLoginAt = new Date();
await this.userRepository.save(user);
return user;
}
// ==================== 管理后台登录 ====================
async adminLogin(dto: AdminLoginDto, ip?: string): Promise<AuthResponse> {
const user = await this.validateLocalUser(dto.email, dto.password);
if (!user) {
throw new UnauthorizedException('邮箱或密码错误');
}
if (!user.isAdmin) {
throw new ForbiddenException('无权访问管理后台');
}
user.lastLoginIp = ip || null;
await this.userRepository.save(user);
const { accessToken, refreshToken, expiresIn } = await this.generateTokens({
sub: user.userId,
type: 'admin',
role: user.role,
});
return {
accessToken,
refreshToken,
expiresIn,
user: {
userId: user.userId,
role: user.role,
displayName: user.displayName,
avatar: user.avatar,
status: user.status,
},
};
}
// ==================== App 登录 ====================
async appLogin(dto: AppLoginDto, ip?: string): Promise<AuthResponse> {
const user = await this.validateLocalUser(dto.phone, dto.password);
if (!user) {
throw new UnauthorizedException('手机号或密码错误');
}
if (user.isAdmin) {
throw new ForbiddenException('管理员请使用后台管理系统登录');
}
user.lastLoginIp = ip || null;
await this.userRepository.save(user);
const { accessToken, refreshToken, expiresIn } = await this.generateTokens({
sub: user.userId,
type: 'app',
role: user.role,
deviceId: dto.deviceId,
});
return {
accessToken,
refreshToken,
expiresIn,
user: {
userId: user.userId,
role: user.role,
displayName: user.displayName,
avatar: user.avatar,
status: user.status,
},
};
}
// ==================== App 注册 ====================
async appRegister(dto: AppRegisterDto): Promise<AuthResponse> {
const existingUser = await this.userRepository.findOne({
where: { phone: dto.phone },
withDeleted: true,
});
if (existingUser && !existingUser.deletedAt) {
throw new ConflictException('该手机号已被注册');
}
if (dto.email) {
const emailExists = await this.userRepository.findOne({
where: { email: dto.email },
});
if (emailExists) {
throw new ConflictException('该邮箱已被使用');
}
}
const saltRounds = this.configService.get<number>('BCRYPT_SALT_ROUNDS', 12);
const user = this.userRepository.create({
phone: dto.phone,
email: dto.email || null,
passwordHash: await bcrypt.hash(dto.password, saltRounds),
displayName: dto.displayName || `用户${dto.phone.slice(-4)}`,
role: UserRole.USER,
status: UserStatus.ACTIVE,
provider: AuthProvider.LOCAL,
failedLoginAttempts: 0,
});
await this.userRepository.save(user);
const { accessToken, refreshToken, expiresIn } = await this.generateTokens({
sub: user.userId,
type: 'app',
role: user.role,
deviceId: dto.deviceId,
});
return {
accessToken,
refreshToken,
expiresIn,
user: {
userId: user.userId,
role: user.role,
displayName: user.displayName,
avatar: user.avatar,
status: user.status,
},
};
}
// ==================== Token 刷新 ====================
async refreshTokens(refreshToken: string): Promise<AuthResponse> {
try {
const payload = await this.jwtService.verifyAsync(refreshToken, {
secret: this.configService.get('JWT_REFRESH_SECRET', 'refresh_lxxw223'),
});
if (payload.tokenType !== 'refresh') {
throw new UnauthorizedException('无效的刷新令牌');
}
const user = await this.userService.findById(payload.sub);
if (!user || !user.isAvailable) {
throw new UnauthorizedException('用户状态异常');
}
const { accessToken, refreshToken: newRefreshToken, expiresIn } = await this.generateTokens({
sub: user.userId,
type: payload.type,
role: user.role,
deviceId: payload.deviceId,
});
return {
accessToken,
refreshToken: newRefreshToken,
expiresIn,
user: {
userId: user.userId,
role: user.role,
displayName: user.displayName,
avatar: user.avatar,
status: user.status,
},
};
} catch (error) {
throw new UnauthorizedException('刷新令牌已过期或无效');
}
}
}
8. AuthController:对外暴露的接口
typescript
// src/modules/auth/auth.controller.ts
import { Controller, Post, Body, UseGuards, Get, Ip } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { AdminLoginDto } from './dto/admin-login.dto';
import { AppLoginDto } from './dto/app-login.dto';
import { AppRegisterDto } from './dto/app-register.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AdminGuard } from './guards/admin.guard';
import { RolesGuard } from './guards/roles.guard';
import { Roles } from './decorators/roles.decorator';
import { CurrentUser } from './decorators/current-user.decorator';
import { User, UserRole } from '../user/entities/user.entity';
@ApiTags('认证')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('admin/login')
@ApiOperation({ summary: '管理后台登录', description: '仅管理员角色可登录' })
async adminLogin(@Body() dto: AdminLoginDto, @Ip() ip: string) {
return this.authService.adminLogin(dto, ip);
}
@Post('app/login')
@ApiOperation({ summary: 'App 用户登录', description: '手机号+密码登录' })
async appLogin(@Body() dto: AppLoginDto, @Ip() ip: string) {
return this.authService.appLogin(dto, ip);
}
@Post('app/register')
@ApiOperation({ summary: 'App 用户注册', description: '手机号注册,注册成功后自动登录' })
async appRegister(@Body() dto: AppRegisterDto) {
return this.authService.appRegister(dto);
}
@Post('refresh')
@ApiOperation({ summary: '刷新令牌', description: '使用 refreshToken 获取新的 accessToken' })
async refreshTokens(@Body() dto: RefreshTokenDto) {
return this.authService.refreshTokens(dto.refreshToken);
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '获取当前登录用户信息' })
getProfile(@CurrentUser() user: User) {
return {
userId: user.userId,
phone: user.phone,
email: user.email,
displayName: user.displayName,
avatar: user.avatar,
role: user.role,
status: user.status,
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt,
};
}
@Get('admin-only')
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '管理员专属接口示例' })
adminOnly(@CurrentUser() user: User) {
return { message: '这是管理员专属内容', admin: user.displayName };
}
@Get('moderator-plus')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN, UserRole.MODERATOR)
@ApiBearerAuth()
@ApiOperation({ summary: '需要 moderator 及以上权限' })
moderatorContent(@CurrentUser() user: User) {
return { message: '这是运营/版主可见内容', user: user.displayName };
}
}
9. 模块组装:让一切运转起来
9.1 AuthModule
typescript
// src/modules/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { User } from '../user/entities/user.entity';
import { UserModule } from '../user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET', 'lxxw223'),
signOptions: {
expiresIn: configService.get<string>('JWT_ADMIN_EXPIRES', '2h'),
},
}),
}),
UserModule,
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, LocalStrategy],
exports: [AuthService],
})
export class AuthModule {}
9.2 UserModule
typescript
// src/modules/user/user.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
typescript
// src/modules/user/user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async findById(userId: string): Promise<User | null> {
return this.userRepository.findOne({
where: { userId },
withDeleted: false,
});
}
async findByPhone(phone: string): Promise<User | null> {
return this.userRepository.findOne({ where: { phone } });
}
async findByEmail(email: string): Promise<User | null> {
return this.userRepository.findOne({ where: { email } });
}
}
9.3 更新后的 AppModule
typescript
// src/app.module.ts(更新后)
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { UserModule } from "./modules/user/user.module";
import { AuthModule } from "./modules/auth/auth.module";
import dbConfig from "./config/db.config";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
useFactory: async () => dbConfig(),
}),
// 移除了全局 JwtModule.register,改为在 AuthModule 中动态配置
UserModule,
AuthModule,
],
})
export class AppModule {}
变更说明:
- 移除了
JwtModule.register的全局注册 - AuthModule 中使用
JwtModule.registerAsync从环境变量读取配置 - 支持 Admin 和 App 使用不同的 Token 有效期策略
10. 接口测试与调试
10.1 管理后台登录
bash
curl -X POST http://localhost:3000/api/auth/admin/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"admin123"}'
10.2 App 登录
bash
curl -X POST http://localhost:3000/api/auth/app/login \
-H "Content-Type: application/json" \
-d '{"phone":"13800138000","password":"user123"}'
10.3 App 注册
bash
curl -X POST http://localhost:3000/api/auth/app/register \
-H "Content-Type: application/json" \
-d '{
"phone":"13800138000",
"password":"user123",
"smsCode":"123456",
"displayName":"音乐爱好者"
}'
10.4 刷新令牌
bash
curl -X POST http://localhost:3000/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"eyJhbGciOiJIUzI1NiIs..."}'
10.5 获取个人信息
bash
curl -X GET http://localhost:3000/api/auth/me \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
10.6 Swagger UI 测试
启动后访问 http://localhost:3000/docs,点击接口右侧的"锁"图标输入 Bearer <token> 即可测试需要认证的接口。
11. 安全设计深度解析
| 安全措施 | 实现方式 | 设计意图 |
|---|---|---|
| 角色隔离 | adminLogin / appLogin 分别校验角色 |
管理员无法通过 App 端登录,普通用户无法登录管理后台 |
| 双 Token 机制 | Access Token + Refresh Token | Access 短期有效,Refresh 长期有效,两者密钥不同 |
| Token 轮换 | 刷新时同时生成新的 Access 和 Refresh | 旧的 Refresh Token 失效,防止窃取后长期滥用 |
| 防暴力破解 | failedLoginAttempts 计数 |
密码错误时累加,正确时清零 |
| 设备绑定 | deviceId 写入 Token |
可检测异常登录设备 |
| 软删除保护 | withDeleted: true |
注册时检查已软删除的手机号,支持恢复策略 |
| bcrypt 哈希 | bcrypt.hash(password, 12) |
独立盐值,抗彩虹表攻击 |
| 实时状态校验 | JwtStrategy 每次请求查库 | 封禁后立即生效,无需等待 Token 过期 |
| 登录 IP 记录 | lastLoginIp 字段 |
安全审计,异常登录检测 |
| 环境隔离 | .env 配置不同密钥 |
不同环境使用不同 JWT 密钥 |
12. 附录:扩展与踩坑
12.1 短信验证码验证
typescript
// 注册时强制验证
await this.smsService.verifyCode(dto.phone, dto.smsCode);
// 登录时可选验证(连续失败 3 次后强制短信验证)
if (user.failedLoginAttempts >= 3) {
await this.smsService.verifyCode(dto.phone, dto.smsCode);
}
12.2 Token 黑名单(登出功能)
typescript
async logout(accessToken: string): Promise<void> {
const decoded = this.jwtService.decode(accessToken) as JwtPayload;
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
await this.redisService.setex(`blacklist:${accessToken}`, ttl, '1');
}
12.3 第三方登录扩展
typescript
@Post('app/oauth/wechat')
async wechatLogin(@Body() dto: WechatLoginDto) {
const userInfo = await this.wechatService.getUserInfo(dto.code);
return this.authService.oauthLogin(AuthProvider.WECHAT, userInfo.openId, userInfo);
}
12.4 常见踩坑
Q: Token 验证通过但 request.user 是 undefined? A: 检查 JwtStrategy.validate 是否 return user,不能返回 null 或 undefined。
Q: @UseGuards(JwtAuthGuard) 返回 401? A: 检查请求头格式是否为 Authorization: Bearer <token>(注意空格),以及 JWT_SECRET 环境变量是否前后端一致。
Q: bcrypt.compare 很慢? A: 盐轮数越高越慢。开发环境可降到 10,生产环境建议保持 12+。
Q: Refresh Token 被盗怎么办? A: 实现 Token 绑定------将 Refresh Token 哈希存储在数据库,刷新时校验。同时绑定设备指纹,异常设备要求重新登录。