前端也能快速入门后端! 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 哈希存储在数据库,刷新时校验。同时绑定设备指纹,异常设备要求重新登录。


相关推荐
XovH1 小时前
Redis 从入门到精通:性能调优与多语言客户端对比
后端
TheITSea1 小时前
一、React初体验:搭建、解析现代开发环境
前端·react.js·前端框架
XovH1 小时前
Redis 从入门到精通:Python + Redis 构建高并发秒杀系统
后端
uhakadotcom1 小时前
结合着 fastapi 使用,anyio 通常可以如何使用 , 它和 uvloop 在性能上有啥差异
后端·面试·github
盒马盒马1 小时前
Rust:String
java·前端·rust
程序猿阿伟1 小时前
《Chrome非必要服务的精细化关闭指南》
前端·chrome·php
belong_my_offer1 小时前
理解前端函数
前端
沐土Arvin2 小时前
中国省市区json数据
前端
狗哥哥2 小时前
统一下载网关技术方案
前端·架构