在NestJS项目中使用TypeORM作为ORM工具时,用户密码的安全存储是核心需求之一。如果直接将明文密码存入数据库,一旦数据库泄露,用户账号将面临极大风险。通过自动哈希处理,可以在数据持久化前自动将明文密码转换为不可逆的哈希值,既减少手动处理的重复代码,也能避免遗漏哈希步骤导致的安全问题。

环境准备与依赖安装
首先需要确保项目已经初始化了NestJS和TypeORM,然后安装密码哈希所需的bcrypt库以及TypeScript类型定义:
npm install bcrypt npm install -D @types/bcrypt
封装bcrypt哈希工具
为了统一哈希逻辑,避免重复代码,我们可以封装一个密码处理工具类,提供哈希和校验两个核心方法:
import * as bcrypt from 'bcrypt';
export class PasswordUtil {
// 盐轮数,数值越高哈希越安全但性能消耗越大,通常取10-12
private static readonly SALT_ROUNDS = 10;
/**
* 对明文密码进行哈希处理
* @param password 明文密码
* @returns 哈希后的密码字符串
*/
static async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, this.SALT_ROUNDS);
}
/**
* 校验明文密码和哈希密码是否匹配
* @param password 明文密码
* @param hashedPassword 哈希后的密码
* @returns 是否匹配
*/
static async comparePassword(password: string, hashedPassword: string): Promise<boolean> {
return bcrypt.compare(password, hashedPassword);
}
}
定义用户实体
创建用户实体类,使用TypeORM的装饰器定义表结构,这里需要添加密码字段,同时排除密码字段在默认查询中的返回,避免敏感信息泄露:
import { Entity, PrimaryGeneratedColumn, Column, BeforeInsert, BeforeUpdate } from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
username: string;
@Column()
password: string;
@Column({ default: true })
isActive: boolean;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
// 临时存储明文密码,用于哈希处理后赋值给password字段
transientPassword: string;
@BeforeInsert()
@BeforeUpdate()
async hashPasswordBeforeSave() {
// 如果传入了明文密码才进行哈希处理
if (this.transientPassword) {
this.password = await PasswordUtil.hashPassword(this.transientPassword);
// 处理完成后清空临时字段
this.transientPassword = undefined;
}
}
}
使用TypeORM实体订阅者实现自动哈希
除了在实体内部使用生命周期钩子,TypeORM还提供了实体订阅者机制,可以在实体插入、更新等事件触发时执行统一逻辑,更适合全局通用的处理逻辑:
创建密码哈希订阅者
import { EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent } from 'typeorm';
import { User } from '../entities/user.entity';
import { PasswordUtil } from '../utils/password.util';
@EventSubscriber()
export class UserPasswordSubscriber implements EntitySubscriberInterface<User> {
/**
* 指定该订阅者监听的实体
*/
listenTo() {
return User;
}
/**
* 插入前触发,处理密码哈希
*/
async beforeInsert(event: InsertEvent<User>) {
if (event.entity.password) {
event.entity.password = await PasswordUtil.hashPassword(event.entity.password);
}
}
/**
* 更新前触发,处理密码哈希
*/
async beforeUpdate(event: UpdateEvent<User>) {
const updatedPassword = event.entity?.password;
// 仅当密码字段被修改时才进行哈希
if (updatedPassword && event.databaseEntity.password !== updatedPassword) {
event.entity.password = await PasswordUtil.hashPassword(updatedPassword);
}
}
}
注册订阅者
在TypeORM的配置中注册该订阅者,确保订阅者能正常生效:
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { UserPasswordSubscriber } from './subscribers/user-password.subscriber';
export const typeOrmConfig: TypeOrmModuleOptions = {
type: 'mysql', // 根据使用的数据库调整
host: '127.0.0.1',
port: 3306,
username: 'root',
password: '123456',
database: 'test_db',
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
subscribers: [UserPasswordSubscriber], // 注册订阅者
synchronize: true, // 开发环境可开启,生产环境建议关闭
};
用户服务与控制器实现
创建用户相关的服务和控制器,测试自动哈希功能是否生效:
用户服务
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
/**
* 创建用户
*/
async createUser(username: string, password: string): Promise<User> {
const user = this.userRepository.create({ username, password });
return this.userRepository.save(user);
}
/**
* 根据用户名查询用户
*/
async findByUsername(username: string): Promise<User> {
return this.userRepository.findOne({ where: { username } });
}
}
用户控制器
import { Controller, Post, Body, Get, Param } from '@nestjs/common';
import { UserService } from './user.service';
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post('register')
async register(
@Body('username') username: string,
@Body('password') password: string,
) {
return this.userService.createUser(username, password);
}
@Get(':username')
async getUser(@Param('username') username: string) {
const user = await this.userService.findByUsername(username);
if (!user) {
return { message: '用户不存在' };
}
// 返回时排除密码字段
const { password, ...result } = user;
return result;
}
}
功能测试
启动项目后,通过接口测试工具发送注册请求:
curl -X POST http://127.0.0.1:3000/users/register
-H "Content-Type: application/json"
-d '{"username":"testuser","password":"123456"}'
查看数据库中的用户表,会发现password字段存储的是以$2b$开头的bcrypt哈希字符串,而不是明文123456。再通过查询接口获取用户信息,返回的结果中不会包含密码字段,说明自动哈希和敏感字段排除都生效了。
注意事项
- bcrypt的盐轮数不要设置过高,否则会影响性能,通常10-12是兼顾安全和性能的取值
- 更新用户密码时,需要确保传入的是明文密码,订阅者才会自动进行哈希处理,如果直接传入哈希后的字符串,会导致二次哈希
- 生产环境中不要开启TypeORM的
synchronize选项,避免表结构自动变更导致数据风险 - 除了密码哈希,还需要配合HTTPS传输、登录失败限制等措施,全面提升应用安全性