前端也能快速入门后端! NestJS前台和后台的Auth认证

项目:music-api(音乐 App 后端)

适用场景:管理后台 (Admin) + Flutter App 双端认证

技术栈:NestJS 11 + TypeORM + Passport + JWT + bcrypt + nanoid

版本:TypeScript 5.7+


目录

  1. 项目背景与前提
  2. 整体架构设计
  3. 环境准备
  4. [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")
  5. [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")
  6. [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")
  7. AuthService:业务逻辑的核心
  8. AuthController:对外暴露的接口
  9. 模块组装:让一切运转起来
  10. 接口测试与调试
  11. 安全设计深度解析
  12. 附录:扩展与踩坑

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
    );
  }
}

实体设计要点

  1. 主键用 nanoidu_${nanoid(10)} 生成 12 位字符串 ID(如 u_a1b2c3d4e5),比自增 ID 更安全(无法被遍历),比 UUID 更短(适合 URL 和日志)。

  2. phone 和 email 可空 :第三方登录用户可能没有手机号或邮箱,因此设为 nullable: true,同时用 where: "phone IS NOT NULL" 保证非空时的唯一性。

  3. passwordHash 可空:第三方登录用户没有密码,这是正常的。

  4. 三个 getter 是认证的核心依赖

    • isAdmin:判断是否为管理员(ADMIN 或 SUPER_ADMIN)
    • isBanned:判断封禁状态(支持永久封禁和临时封禁)
    • isAvailable:综合判断用户是否可用(活跃 + 未封禁 + 未删除)
  5. 软删除@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/login
  • new 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 {}

存在的问题

  1. 硬编码密钥secret: "lxxw223" 应该来自环境变量
  2. 单一有效期expiresIn: "120s" 对所有 Token 生效,无法区分 Admin 和 App 的不同有效期需求
  3. 全局注册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_SECRETJWT_REFRESH_SECRET 必须是不同的值。如果相同,攻击者拿到 Refresh Token 后可以直接当作 Access Token 使用,绕过短有效期限制。


4. DTO 层:请求的守门员

DTO(Data Transfer Object)配合 class-validatorclass-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;
  }
}

关键设计

  1. passReqToCallback: true :将 Request 对象传给 validate,后续可做设备绑定校验
  2. 每次请求查数据库:用户被封禁后,已有 Token 立即失效,不需要等待过期
  3. 返回完整 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,不能返回 nullundefined

Q: @UseGuards(JwtAuthGuard) 返回 401? A: 检查请求头格式是否为 Authorization: Bearer <token>(注意空格),以及 JWT_SECRET 环境变量是否前后端一致。

Q: bcrypt.compare 很慢? A: 盐轮数越高越慢。开发环境可降到 10,生产环境建议保持 12+。

Q: Refresh Token 被盗怎么办? A: 实现 Token 绑定------将 Refresh Token 哈希存储在数据库,刷新时校验。同时绑定设备指纹,异常设备要求重新登录。


相关推荐
大圣编程43 分钟前
Python中continue语句的用法是什么?
开发语言·前端·python
yuhaiqiang44 分钟前
随手 vibecoding 的浏览器插件已经 6000 多次下载,聊聊他的产品设计
前端·后端·面试
之歆1 小时前
Vue商品详情与放大镜组件
前端·javascript·vue.js
再吃一根胡萝卜2 小时前
如何把小米 MiMo 接入 CodeBuddy,打造私有 Agent
前端
geovindu2 小时前
python: Functional Options Pattern
开发语言·后端·python·设计模式·惯用法模式·函数式选项模式
负责的蛋挞3 小时前
异步HttpModule的实现方式
java·服务器·前端
卷无止境4 小时前
C++ 存储类说明符(Storage Class Specifier)大横评
c++·后端
用户019027581614 小时前
量化数据的 batch 接口有多好用?从 1 只到 500 只,批量拉数据的正确姿势
后端
rruining4 小时前
Java设计模式——结构型
后端
卷无止境4 小时前
C++ 编程的一大坑:非常量全局变量是"万恶之源"
c++·后端