Skip to content
构建带校验与鉴权的 REST API
使用 ConfigModule、TypeOrmModule、ValidationPipe 和 Guard 构建一个可工作的用户模块,需要同时协调配置加载、数据库连接、DTO 校验与 JWT 鉴权。本章按 配置 → 数据库 → 校验 → 鉴权 → 路由保护 的顺序展示完整过程,每个阶段给出可直接运行的代码及行为说明。
1. 配置管理
先安装必要的依赖。数据库驱动按实际情况选择,示例使用 mysql2,若用 PostgreSQL 可换成 pg。
bash
npm install @nestjs/config @nestjs/typeorm typeorm mysql2 \
@nestjs/jwt @nestjs/passport passport passport-jwt \
bcrypt class-validator class-transformer
npm install -D @types/bcrypt@nestjs/config 负责从 .env 加载环境变量,class-validator 与 class-transformer 提供 DTO 校验所需的装饰器,bcrypt 用于密码哈希。
项目根目录下创建 .env 文件:
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=secret
DB_DATABASE=nest_practice
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=3600s要让这些变量在整个应用中可用,在 AppModule 中导入 ConfigModule.forRoot() 并设为全局模块。
typescript
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
],
})
export class AppModule {}isGlobal: true 意味着后续任何模块都不需要重复导入 ConfigModule,可直接在构造函数中注入 ConfigService 来读取配置。
2. 数据库连接与用户实体
数据库连接参数需要从 ConfigService 获取,因此使用 TypeOrmModule.forRootAsync。
typescript
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
useFactory: (config: ConfigService) => ({
type: 'mysql',
host: config.get<string>('DB_HOST'),
port: config.get<number>('DB_PORT'),
username: config.get<string>('DB_USERNAME'),
password: config.get<string>('DB_PASSWORD'),
database: config.get<string>('DB_DATABASE'),
autoLoadEntities: true,
synchronize: true,
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}synchronize: true 指示 TypeORM 按实体定义自动调整表结构,仅适合开发阶段。autoLoadEntities: true 会让项目内的所有实体类被自动发现,无需在 entities 数组中逐一列出。
接着定义用户实体 User:
typescript
// user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
username: string;
@Column()
password: string;
@CreateDateColumn()
createdAt: Date;
}@Entity() 将类映射为数据库表,@PrimaryGeneratedColumn() 生成自增主键,@Column() 标注普通列,unique: true 约束用户名唯一,@CreateDateColumn() 在插入时自动填入当前时间。
实体必须在某个模块中注册,以便注入 Repository<User>。创建 UserModule:
typescript
// user.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UserService } from './user.service';
import { UserController } from './user.controller';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService],
controllers: [UserController],
exports: [UserService],
})
export class UserModule {}TypeOrmModule.forFeature([User]) 会为当前模块注册 User 对应的仓库,之后 UserService 通过 @InjectRepository(User) 即可操作数据库。
3. DTO 与校验规则
为注册和登录请求定义明确的数据结构,并附加校验装饰器。
typescript
// dto/create-user.dto.ts
import { IsString, MinLength, MaxLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(3)
@MaxLength(20)
username: string;
@IsString()
@MinLength(6)
@MaxLength(30)
password: string;
}typescript
// dto/login.dto.ts
import { IsString } from 'class-validator';
export class LoginDto {
@IsString()
username: string;
@IsString()
password: string;
}登录仅校验类型,不做长度约束。DTO 将用在控制器方法的 @Body() 参数上,ValidationPipe 根据装饰器规则对请求体进行校验。
4. 全局校验管道
在 main.ts 中启用全局 ValidationPipe:
typescript
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
await app.listen(3000);
}
bootstrap();whitelist: true— 自动剥离 DTO 中未声明的属性,避免客户端意外传入role等字段。transform: true— 将普通 JSON 对象转换为 DTO 类的实例,这样 DTO 里定义的类型(如number)与装饰器才能正确工作。
管道在控制器方法执行前运行,校验失败时直接返回 400 Bad Request,响应体中包含详细错误信息。
5. JWT 认证
新建 AuthModule,在其中通过 JwtModule.registerAsync 从环境变量读取密钥和过期时间。
typescript
// auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
import { AuthService } from './auth.service';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
signOptions: { expiresIn: config.get<string>('JWT_EXPIRES_IN') },
}),
inject: [ConfigService],
}),
],
providers: [JwtStrategy, AuthService],
exports: [AuthService],
})
export class AuthModule {}JWT 策略从请求头中的 Authorization: Bearer <token> 提取令牌并验证,通过后将荷载挂载到 request.user。
typescript
// jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
export interface JwtPayload {
sub: number;
username: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
});
}
async validate(payload: JwtPayload) {
return { userId: payload.sub, username: payload.username };
}
}secretOrKey 通过 ConfigService 读取,避免硬编码。validate 的返回值将作为 request.user。
签发 Token 的逻辑封装在 AuthService 中:
typescript
// auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { JwtPayload } from './jwt.strategy';
@Injectable()
export class AuthService {
constructor(private readonly jwtService: JwtService) {}
async signToken(userId: number, username: string): Promise<string> {
const payload: JwtPayload = { sub: userId, username };
return this.jwtService.sign(payload);
}
}6. 路由保护
创建一个继承 AuthGuard('jwt') 的守卫类,方便在控制器上通过 @UseGuards() 引用。
typescript
// jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}任何路由加上 @UseGuards(JwtAuthGuard) 后,请求必须携带合法 JWT,否则返回 401 Unauthorized。
7. 用户接口
UserService 负责注册、查找用户,以及密码哈希与比对。
typescript
// user.service.ts
import { Injectable, ConflictException, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './user.entity';
import { CreateUserDto } from './dto/create-user.dto';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async register(createUserDto: CreateUserDto) {
const { username, password } = createUserDto;
const existing = await this.userRepository.findOne({ where: { username } });
if (existing) {
throw new ConflictException('用户名已被注册');
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = this.userRepository.create({
username,
password: hashedPassword,
});
await this.userRepository.save(user);
const { password: _, ...result } = user;
return result;
}
async findByUsername(username: string): Promise<User | undefined> {
return this.userRepository.findOne({ where: { username } });
}
async findById(id: number): Promise<User | undefined> {
return this.userRepository.findOne({ where: { id } });
}
}注册时检查用户名唯一性,密码经过 bcrypt.hash 处理,返回的对象不包含 password 字段。
UserController 汇聚注册、登录、获取用户信息三个端点。
typescript
// user.controller.ts
import {
Controller, Post, Get, Body, UseGuards, Req, UnauthorizedException,
} from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { LoginDto } from './dto/login.dto';
import { AuthService } from '../auth/auth.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import * as bcrypt from 'bcrypt';
@Controller('users')
export class UserController {
constructor(
private readonly userService: UserService,
private readonly authService: AuthService,
) {}
@Post('register')
async register(@Body() createUserDto: CreateUserDto) {
return this.userService.register(createUserDto);
}
@Post('login')
async login(@Body() loginDto: LoginDto) {
const user = await this.userService.findByUsername(loginDto.username);
if (!user) {
throw new UnauthorizedException('用户名或密码错误');
}
const isMatch = await bcrypt.compare(loginDto.password, user.password);
if (!isMatch) {
throw new UnauthorizedException('用户名或密码错误');
}
const token = await this.authService.signToken(user.id, user.username);
return { access_token: token };
}
@UseGuards(JwtAuthGuard)
@Get('profile')
async getProfile(@Req() req) {
const user = await this.userService.findById(req.user.userId);
if (!user) {
throw new UnauthorizedException();
}
const { password, ...result } = user;
return result;
}
}AppModule 需导入 UserModule 和 AuthModule,确保依赖关系完整:
typescript
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { UserModule } from './user/user.module';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
useFactory: (config: ConfigService) => ({
type: 'mysql',
host: config.get<string>('DB_HOST'),
port: config.get<number>('DB_PORT'),
username: config.get<string>('DB_USERNAME'),
password: config.get<string>('DB_PASSWORD'),
database: config.get<string>('DB_DATABASE'),
autoLoadEntities: true,
synchronize: true,
}),
inject: [ConfigService],
}),
UserModule,
AuthModule,
],
})
export class AppModule {}8. 运行验证
启动应用后,可通过 HTTP 客户端测试几个典型场景。
1. 注册时传入无效数据
POST /users/register
Content-Type: application/json
{
"username": "ab",
"password": "123"
}username 最小长度 3,password 最小长度 6,ValidationPipe 会阻断请求并返回 400,以及具体错误列表:
json
{
"statusCode": 400,
"message": [
"username must be longer than or equal to 3 characters",
"password must be longer than or equal to 6 characters"
],
"error": "Bad Request"
}2. 访问受保护路由不带 Token
GET /users/profile返回 401:
json
{
"statusCode": 401,
"message": "Unauthorized"
}具体响应体格式取决于 Passport 策略的实现,但状态码 401 是确定的。
3. 登录成功后携带 Token 访问 profile
先登录获得令牌:
POST /users/login
{
"username": "validuser",
"password": "rightpassword"
}返回:
json
{
"access_token": "eyJhbGciOi..."
}再用该令牌请求:
GET /users/profile
Authorization: Bearer eyJhbGciOi...得到:
json
{
"id": 1,
"username": "validuser",
"createdAt": "2025-01-15T08:12:00.000Z"
}password 不会出现在响应中。
9. 注意事项
bcrypt 依赖必须安装
运行前需确保已执行npm install bcrypt @types/bcrypt,否则会编译或运行时出错。synchronize 仅限开发
开发时便于自动同步表结构,实际使用应设为false并采用迁移工具管理数据库变更。Guard 在 Pipe 之前执行
NestJS 请求生命周期中,守卫先于管道运行。因此,鉴权失败(Token 过期或无效)时不会触发 DTO 校验,这是预期行为,无需在 Guard 中额外处理。密码字段不应随用户数据返回
服务方法中通过解构显式去掉password字段。若使用@Exclude()与class-transformer序列化,需注意只有调用classToPlain时才生效,直接返回实体对象仍会泄露密码。最稳妥的做法是在服务层手动剔除。ValidationPipe 的 whitelist 影响
开启后,客户端额外传入的字段会被丢弃,有助于防止 mass assignment 攻击。如果前端依赖这些额外字段(如回显),需在文档中明确说明其行为。
10. 参考链接
[1] NestJS 官方文档 – Configuration (https://docs.nestjs.com/techniques/configuration)
[2] NestJS 官方文档 – Database (https://docs.nestjs.com/techniques/database)
[3] TypeORM 官方文档 – Entities (https://typeorm.io/entities)
[4] NestJS 官方文档 – Validation (https://docs.nestjs.com/techniques/validation)
[5] NestJS 官方文档 – Pipes (https://docs.nestjs.com/pipes)
[6] NestJS 官方文档 – Authentication (https://docs.nestjs.com/security/authentication)
[7] NestJS 官方文档 – Guards (https://docs.nestjs.com/guards)
[8] NestJS 官方文档 – Request Lifecycle (https://docs.nestjs.com/faq/request-lifecycle)
