十、数据脱敏(nestjs+next.js从零开始一步一步创建通用后台管理系统)

235 阅读3分钟

在第九章我们的示例中用户注册后返回了加密的密码,如下图:

1747724082962.png 但是我们不想让前端看到这个密码,所以返回前必须删除这个密码。或者数据需要脱敏,如手机号中间用星号代替。如果要在每个业务逻辑处手工进行处理,如删除密码,那后期如果调整了逻辑就必须修改业务逻辑代码,这是个不好的设计。

1、官方处理方案

官方提供了ClassSerializerInterceptor拦截器,可以用注解的方式在传输对象进行序列化时根据注解对这些字段进行特殊处理。 示例代码: 在用户注册接口上增加拦截器注解(注:这个注解也可以加载dto或entity对象上):

//auth.controller.ts
@Post("signup")
    @AllowNoToken()
    @UseInterceptors(ClassSerializerInterceptor)
    async registerUser(@Body() createUserDto: CreateUserDto):Promise<OutUserDto>{
      const dto=await this.authService.signUp(createUserDto)
      return new OutUserDto({...dto})
    }

新建一个OutUserDto传输对象,代码如下: 注意password上增加了一个排除注解。 constructor构造函数是在生成对象时可以用解析的方式,即return new OutUserDto({...dto})。

//create-user.dto.ts
import { Exclude } from 'class-transformer'
export class OutUserDto {
  username: string

  @Exclude()//排除密码属性,不会被序列化
  password: string
  //....其他属性

  constructor(partial: Partial<OutUserDto>) {
    Object.assign(this, partial)
  }
}

在postman中使用注册功能,返回结果如下:

image.png

可见返回结果已不包含密码字段。 因为数据脱敏需要自定义业务规则,所以我们需要学会自定义拦截器方法解决这个问题。

2、自定义序列化拦截器

2.1、创建拦截器

nest g itc common/interceptors/serialize --flat --no-spec

2.2、自定义拦截器

//serialize.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { Observable, map } from 'rxjs';

@Injectable()
export class SerializeInterceptor implements NestInterceptor {
  constructor(private dto: any) {} // 注入dto
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next
      .handle()
      .pipe(
        map(data => {
          //  将data转为dto实例
          return plainToInstance(this.dto,data,{
            //设置为true后,所有经过该拦截器的接口都需要配置Expose或Exclude的注解
            //设置为false后,所有接口都需要配置Expose的注解
            //Expose 需要暴露的属性
            //Exclude 需要过滤掉的属性
            excludeExtraneousValues: false,// 严格模式
            // 是否允许隐式转换
            enableImplicitConversion: true
          });
        }),
      );
  }
}

2.2、自定义注解,简化使用

//serialize.decorator.ts
import { SetMetadata, UseInterceptors } from '@nestjs/common';
import { SerializeInterceptor } from '../interceptors/serialize.interceptor';

interface ClassConstructor {
  new (...args: any[]): any;
}
export const Serialize = (dto: ClassConstructor) => {
  return UseInterceptors(new SerializeInterceptor(dto));
};

2.3、使用

修改注册接口注解:

 @Post("signup")
    @AllowNoToken()
    @Serialize(OutUserDto)//使用自定义注解拦截返回对象,过滤掉密码
    async registerUser(@Body() createUserDto: CreateUserDto):Promise<OutUserDto>{
      const dto=await this.authService.signUp(createUserDto) 
      return new OutUserDto({...dto,createTime:new Date('2024-01-01'),roles:['user','admin']})
    }

2.4、测试

image.png

3、手机脱敏示例

3.1、创建手机号脱敏装饰器

// src/decorators/phone-mark.decorator.ts
import { applyDecorators } from '@nestjs/common';
import { Transform } from 'class-transformer';

export const PHONE_MARK_KEY = 'phoneMark';

export function PhoneMark(pattern = '$1****$2') { 

  return applyDecorators(
    Transform(({ value }) => {
      if (typeof value === 'string') {
        return value.replace(/(\d{3})\d{4}(\d{4})/, pattern);
      }
      return value;
    }),
    (target: object, propertyKey: string) => {
      Reflect.defineMetadata(PHONE_MARK_KEY, true, target, propertyKey);
    }
  );
}

3.2、实现手机号脱敏拦截器

// src/interceptors/phone-mask.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { instanceToPlain } from 'class-transformer';
import { Reflector } from '@nestjs/core';
import { PHONE_MARK_KEY } from '../decorators/phone-mark.decorator';

@Injectable()
export class PhoneMaskInterceptor implements NestInterceptor {
  constructor(private readonly reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map(data => {
        const response = context.switchToHttp().getResponse();
        if (response.statusCode >= 200 && response.statusCode < 300) {
          return this.maskPhoneNumbers(data);
        }
        return data;
      })
    );
  }

  private maskPhoneNumbers(data: any): any {
    const maskPhones = (obj: any) => {
      if (!obj || typeof obj !== 'object') return obj;

      if (Array.isArray(obj)) {
        return obj.map(item => maskPhones(item));
      }

      const instance = instanceToPlain(obj);
      return Object.keys(instance).reduce((acc, key) => {
        const value = instance[key];
        //const isPhoneMarked = this.reflector.get<boolean>(PHONE_MARK_KEY, obj.constructor.prototype, key);
        const isPhoneMarked = this.reflector.get<boolean>(PHONE_MARK_KEY, obj.constructor);
        if (isPhoneMarked && typeof value === 'string') {
          acc[key] = value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
        } else if (typeof value === 'object') {
          acc[key] = maskPhones(value);
        } else {
          acc[key] = value;
        }
        return acc;
      }, {});
    };

    return maskPhones(data);
  }
}

3.3、创建应用装饰器

// src/decorators/use-phone-mask.decorator.ts
import { applyDecorators, UseInterceptors } from '@nestjs/common';
import { PhoneMaskInterceptor } from '../interceptors/phone-mask.interceptor';

export function UsePhoneMask() {
  return applyDecorators(
    UseInterceptors(PhoneMaskInterceptor)
  );
}

3.4、DTO使用示例

//create-user.dto.ts
import { Exclude, Type } from 'class-transformer'
import { PhoneMark } from 'src/common/decorators/phone-mark.decorator';
export class OutUserDto {
  username: string

  @Exclude()//排除密码属性,不会被序列化
  password: string
  
  //手机脱敏
  @PhoneMark()
  phone: string;

  @Type(() => Date)
  createTime: Date
  roles: string[]
  
  constructor(partial: Partial<OutUserDto>) {
    Object.assign(this, partial)
  }
}

3.5、控制器增加手机号返回

@Post("signup")
    @AllowNoToken()
    @Serialize(OutUserDto)
    async registerUser(@Body() createUserDto: CreateUserDto):Promise<OutUserDto>{
      const dto=await this.authService.signUp(createUserDto) 
      return new OutUserDto({...dto,phone:'19213648576',roles:['user','admin']})
    }

3.6、测试

image.png