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 {}