返回文章列表 →

Note

认证

本章节讲述使用bcrypt + JWT来完成认证

发布于 更新于

流程

后端的认证流程大同小异,基本上都是按照下面这个顺序

一、注册阶段
接收用户信息
→ 参数验证
→ 密码哈希
→ 保存用户


二、首次登录阶段
接收身份凭证
→ 查询用户
→ 判断是否匹配(通过验证密码、验证码或第三方身份等等)
→ 检查账号状态
→ 生成 Session 或 Token,即登录凭证
→ 返回登录凭证给客户端


三、后续请求阶段
客户端携带登录凭证
→ 后端提取凭证
→ 验证凭证
→ 得到当前用户
→ 设置请求上下文中的用户信息


四、授权阶段
读取用户角色、权限和资源关系
→ 判断是否允许当前操作

五、执行业务
上述认证和授权流程完毕后,执行具体的业务

技术

实现认证的技术有很多种,这里使用 bcrypt + JWT + 自定义守卫

概念 本质 负责什么 出现在哪个阶段
bcrypt 密码哈希工具 保存密码、校验密码 账号密码的注册和登录
JWT Token 表示登录后的身份信息 登录成功后、访问受保护接口时
Access Token Token 的用途 用来访问受保护接口
Bearer HTTP 认证方案 规定客户端怎样携带 Token
Guard NestJS 请求守卫 启动认证流程,并决定是否放行
request.user 当前请求的认证结果 保存已经认证的用户信息

详细流程

用户注册    (POST /auth/register)
→ bcrypt 哈希密码   (bcrypt.hash())
→ 保存用户  (保存 passwordHash)

用户登录    (POST /auth/login)
→ 查询用户
→ bcrypt 校验密码   (bcrypt.compare())
→ 生成 JWT (JwtService.signAsync())
→ 返回给客户端 (返回 access_token)

访问受保护接口 (GET /auth/profile)
→ 守卫校验  (JwtAuthGuard)
→ Guard 提取 Bearer Token   (Authorization: Bearer access_token)
→ 验证 JWT  (JwtService.verifyAsync())
→ 写入 request.user
→ 进入 Controller

实现

通过注册和登录以及访问权限接口来实现认证流程

项目结构

src
├─ main.ts
├─ app.module.ts
│
├─ users
│  ├─ user.interface.ts
│  ├─ users.service.ts
│  └─ users.module.ts
│
└─ auth
   ├─ dto
   │  ├─ register.dto.ts
   │  └─ login.dto.ts
   │
   ├─ jwt-payload.interface.ts
   ├─ authenticated-request.interface.ts
   ├─ jwt-auth.guard.ts
   ├─ auth.service.ts
   ├─ auth.controller.ts
   └─ auth.module.ts

安装依赖

npm install bcrypt @nestjs/jwt @nestjs/config
npm install class-validator class-transformer

1. 配置环境变量

.env

JWT_SECRET = "请替换为一个足够长并且随机的密钥";
// 过期时间,15分钟
JWT_EXPIRES_IN_SECONDS = 900;

2. 书写用户相关逻辑

用户数据接口

user.interface.ts

export type UserRole = "user" | "admin";

export type UserStatus = "active" | "disabled";

/**
 * 数据库中的完整用户类型
 *
 * passwordHash 只允许在后端内部使用,不能返回给客户端
 */
export interface User {
  id: number;
  username: string;
  passwordHash: string;
  role: UserRole;
  status: UserStatus;
}

/**
 * 可以安全返回给客户端的用户类型
 *
 * Omit<User, 'passwordHash'> 表示:
 * 从 User 类型中去掉 passwordHash 字段
 */
export type SafeUser = Omit<User, "passwordHash">;

用户服务

users.service.ts

import { Injectable } from "@nestjs/common";
import { SafeUser, User, UserRole } from "./user.interface";

interface CreateUserInput {
  username: string;
  passwordHash: string;
  role?: UserRole;
}

@Injectable()
export class UsersService {
  /**
   * 为了方便演示,暂时使用内存数组代替数据库
   */
  private readonly users: User[] = [];

  private nextId = 1;

  /**
   * 根据用户名查询完整用户
   *
   * 登录时需要读取 passwordHash,因此这里返回 User,而不是 SafeUser
   */
  async findByUsername(username: string): Promise<User | undefined> {
    return this.users.find((user) => user.username === username);
  }

  /**
   * 根据用户 ID 查询用户
   *
   * JwtAuthGuard 验证 JWT 后,会根据 payload.sub 查询当前用户
   */
  async findById(id: number): Promise<User | undefined> {
    return this.users.find((user) => user.id === id);
  }

  /**
   * 创建用户
   *
   * 这里接收的必须是 passwordHash,不能接收和保存明文密码
   */
  async create(input: CreateUserInput): Promise<User> {
    const user: User = {
      id: this.nextId++,
      username: input.username,
      passwordHash: input.passwordHash,
      role: input.role ?? "user",
      status: "active",
    };

    this.users.push(user);

    return user;
  }

  /**
   * 移除 passwordHash,得到可以安全返回的用户对象
   */
  toSafeUser(user: User): SafeUser {
    const { passwordHash, ...safeUser } = user;

    return safeUser;
  }
}

用户模块

users.module.ts

import { Module } from "@nestjs/common";
import { UsersService } from "./users.service";

@Module({
  providers: [UsersService],

  /**
   * AuthModule 需要使用 UsersService,所以必须将它导出
   */
  exports: [UsersService],
})
export class UsersModule {}

3. 创建登录和注册 DTO

注册 DTO

register.dto.ts

import { IsString, Length, MinLength } from "class-validator";

export class RegisterDto {
  @IsString()
  @Length(3, 30)
  username: string;

  @IsString()
  @MinLength(8)
  password: string;
}

登录 DTO

login.dto.ts

import { IsNotEmpty, IsString } from "class-validator";

export class LoginDto {
  @IsString()
  @IsNotEmpty()
  username: string;

  @IsString()
  @IsNotEmpty()
  password: string;
}

4. 定义 request.user 类型

authenticated-request.interface.ts

import { Request } from "express";
import { UserRole } from "../users/user.interface";

/**
 * JWT 验证成功后,放进 request.user 的内容
 */
export interface AuthenticatedUser {
  userId: number;
  username: string;
  role: UserRole;
}

/**
 * 在 Express Request 基础上增加 user 属性
 */
export interface AuthenticatedRequest extends Request {
  user: AuthenticatedUser;
}

5. 书写认证相关逻辑

JWT 载荷接口

jwt-payload.interface.ts

import { UserRole } from "../users/user.interface";

/**
 * 服务器生成 JWT 时主动放进去的数据
 */
export interface JwtPayload {
  /**
   * subject,JWT 代表的主体
   * 这里存用户 ID
   */
  sub: string;

  username: string;

  role: UserRole;

  /**
   * 签发时间和过期时间通常由 JWT 库自动加入
   */
  iat?: number;

  exp?: number;
}

认证服务

auth.service.ts

import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcrypt";

import { UsersService } from "../users/users.service";
import { SafeUser } from "../users/user.interface";
import { RegisterDto } from "./dto/register.dto";
import { LoginDto } from "./dto/login.dto";
import { JwtPayload } from "./jwt-payload.interface";

interface LoginResult {
  access_token: string;
  token_type: "Bearer";
}

@Injectable()
export class AuthService {
  /**
   * bcrypt 的成本因子
   */
  private readonly bcryptRounds = 12;

  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService
  ) {}

  /**
   * 用户注册
   */
  async register(dto: RegisterDto): Promise<SafeUser> {
    // 1. 查询用户名是否已经存在
    const existingUser = await this.usersService.findByUsername(dto.username);

    if (existingUser) {
      throw new ConflictException("用户名已经存在");
    }

    /*
     * bcrypt 只处理输入的前72个字节
     * 这里主动限制 UTF-8 字节长度
     */
    const passwordByteLength = Buffer.byteLength(dto.password, "utf8");

    if (passwordByteLength > 72) {
      throw new BadRequestException("密码的 UTF-8 编码长度不能超过72字节");
    }

    // 2. 将明文密码转换成 bcrypt 哈希
    const passwordHash = await bcrypt.hash(dto.password, this.bcryptRounds);

    // 3. 保存用户和密码哈希
    const user = await this.usersService.create({
      username: dto.username,
      passwordHash,
    });

    // 4. 返回时移除 passwordHash
    return this.usersService.toSafeUser(user);
  }

  /**
   * 用户登录
   */
  async login(dto: LoginDto): Promise<LoginResult> {
    // 1. 根据用户名查询用户
    const user = await this.usersService.findByUsername(dto.username);

    /*
     * 用户不存在和密码错误返回相同的信息,
     * 避免暴露某个用户名是否真实存在
     */
    if (!user) {
      throw new UnauthorizedException("用户名或密码错误");
    }

    // 2. 校验明文密码和数据库哈希
    const passwordMatched = await bcrypt.compare(dto.password, user.passwordHash);

    if (!passwordMatched) {
      throw new UnauthorizedException("用户名或密码错误");
    }

    // 3. 检查用户当前状态
    if (user.status !== "active") {
      throw new ForbiddenException("账号当前不可用");
    }

    // 以上流程都通过,说明登录成功,可以返回JWT了

    // 4. 创建 JWT Payload
    const payload: JwtPayload = {
      sub: String(user.id),
      username: user.username,
      role: user.role,
    };

    // 5. 生成 JWT
    const accessToken = await this.jwtService.signAsync(payload);

    // 6. 返回 JWT
    return {
      access_token: accessToken,
      token_type: "Bearer",
    };
  }
}

JWT 认证守卫

jwt-auth.guard.ts

import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";

import { UsersService } from "../users/users.service";
import { AuthenticatedRequest } from "./authenticated-request.interface";
import { JwtPayload } from "./jwt-payload.interface";

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(
    private readonly jwtService: JwtService,
    private readonly usersService: UsersService
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    /*
     * 1. 取得当前 HTTP 请求对象
     */
    const request = context.switchToHttp().getRequest<AuthenticatedRequest>();

    /*
     * 2. 从 Authorization 请求头中提取 JWT
     */
    const token = this.extractTokenFromHeader(request);

    if (!token) {
      throw new UnauthorizedException("缺少 Bearer Token");
    }

    try {
      /*
       * 3. 验证 JWT
       *
       * verifyAsync() 会验证:
       * - JWT 格式
       * - JWT 签名
       * - JWT 是否过期
       * - 配置的算法等条件
       */
      const payload = await this.jwtService.verifyAsync<JwtPayload>(token);

      /*
       * 4. 根据 payload.sub 查询当前用户
       *
       * 这一步不是验证 JWT 签名所必需的,
       * 但可以确认用户仍然存在并且未被禁用
       */
      const userId = Number(payload.sub);

      const user = await this.usersService.findById(userId);

      if (!user || user.status !== "active") {
        throw new UnauthorizedException();
      }

      /*
       * 5. 将当前认证用户写入 request.user
       *
       * 后续 Controller 和其他 Guard
       * 就可以读取 request.user
       */
      request.user = {
        userId: user.id,
        username: user.username,
        role: user.role,
      };

      /*
       * 6. 返回 true,允许请求继续
       */
      return true;
    } catch {
      /*
       * JWT 签名错误、已经过期、格式不正确,
       * 或者用户不存在时,都会进入这里
       */
      throw new UnauthorizedException("登录凭证无效或已过期");
    }
  }

  /**
   * 从 Authorization 请求头中提取 JWT
   *
   * 请求头格式:
   * Authorization: Bearer JWT内容
   */
  private extractTokenFromHeader(request: AuthenticatedRequest): string | undefined {
    const authorization = request.headers.authorization;

    /*
     * 例如:
     *
     * authorization =
     * "Bearer eyJhbGciOiJIUzI1Ni..."
     *
     * 拆分后:
     *
     * type  = "Bearer"
     * token = "eyJhbGciOiJIUzI1Ni..."
     */
    const [type, token] = authorization?.split(" ") ?? [];

    return type === "Bearer" ? token : undefined;
  }
}

认证控制器

auth.controller.ts

import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, UseGuards } from "@nestjs/common";

import { AuthService } from "./auth.service";
import { AuthenticatedRequest } from "./authenticated-request.interface";
import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto";
import { JwtAuthGuard } from "./jwt-auth.guard";

@Controller("auth")
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  /**
   * 注册接口
   *
   * 不需要 JWT
   */
  @Post("register")
  register(@Body() dto: RegisterDto) {
    return this.authService.register(dto);
  }

  /**
   * 登录接口
   *
   * 不需要 JWT
   * 登录成功后返回 JWT
   */
  @HttpCode(HttpStatus.OK)
  @Post("login")
  login(@Body() dto: LoginDto) {
    return this.authService.login(dto);
  }

  /**
   * 受保护接口
   *
   * 请求进入 Controller 前,
   * 会先执行 JwtAuthGuard
   */
  @UseGuards(JwtAuthGuard)
  @Get("profile")
  getProfile(@Req() request: AuthenticatedRequest) {
    return request.user;
  }
}

认证模块

auth.module.ts

import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { JwtModule, JwtModuleOptions } from "@nestjs/jwt";
import { UserModule } from "src/users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtAuthGuard } from "./jwt-auth.guard";

@Module({
  // 导入依赖模块
  imports: [
    UserModule,

    // JWT模块,使用异步工厂函数配置
    JwtModule.registerAsync({
       // 导入ConfigModule。AppModule使用ConfigModule把.env设置为了全局。这里不使用环境变量可以不引入 ConfigModule
      imports: [ConfigModule],
      // 注入ConfigService
      inject: [ConfigService],

      // 工厂函数返回JWT配置
      useFactory: (configService: ConfigService): JwtModuleOptions => {
        // JWT密钥,从环境变量中读取
        const secret = configService.get<string>("JWT_SECRET");

        if (!secret) {
          throw new Error("缺少 JWT_SECRET 环境变量");
        }

        // JWT过期时间,从环境变量中读取,默认15分钟
        const expiresIn = Number(configService.get<string>("JWT_EXPIRES_IN_SECONDS") ?? "900");

        if (!Number.isFinite(expiresIn) || expiresIn <= 0) {
          throw new Error("JWT_EXPIRES_IN_SECONDS 必须是正数");
        }

        return {
          // JWT密钥
          secret,

          // JWT签名选项配置
          signOptions: {
            algorithm: "HS256",
            expiresIn,
          },

          //   JWT验证规则配置
          verifyOptions: {
            algorithms: ["HS256"],
          },
        };
      },
    }),
  ],

  controllers: [AuthController],
  providers: [AuthService, JwtAuthGuard],
})
export class AuthModule {}

根模块配置

app.module.ts

import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";

import { AuthModule } from "./auth/auth.module";

@Module({
  imports: [
    // 全局配置模块,加载.env文件中的环境变量
    ConfigModule.forRoot({
      // 设置为全局模块,所有子模块无需再次导入环境变量
      isGlobal: true,
    }),

    AuthModule,
  ],
})
export class AppModule {}