feat(api-security): encrypt user fields and ai usage logs

This commit is contained in:
2026-04-06 15:55:27 +08:00
parent 13abfc1e52
commit 4c6aeb3e6c
9 changed files with 595 additions and 67 deletions
+8 -4
View File
@@ -464,8 +464,8 @@ export class AiService {
return {
id: log.id,
channel: log.channel,
providerName: log.providerName,
model: log.model,
providerName: this.readDecryptedString(log.providerName),
model: this.readDecryptedString(log.model),
promptTokens: log.promptTokens,
completionTokens: log.completionTokens,
totalTokens: log.totalTokens,
@@ -730,8 +730,12 @@ export class AiService {
data: {
userId: input.userId,
channel: input.channel,
providerName: input.providerName,
model: input.model,
providerName:
input.providerName === null
? null
: this.dataEncryptionService.encryptString(input.providerName),
model:
input.model === null ? null : this.dataEncryptionService.encryptString(input.model),
promptTokens: input.usage?.promptTokens ?? 0,
completionTokens: input.usage?.completionTokens ?? 0,
totalTokens: input.usage?.totalTokens ?? 0,
+36 -5
View File
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
import { authenticator } from "@otplib/preset-default";
import { AuthMailService } from "./auth-mail.service";
import { PrismaService } from "../prisma/prisma.service";
import { DataEncryptionService } from "../security/data-encryption.service";
type EmailCodeEntry = {
code: string;
@@ -33,7 +34,8 @@ export class AuthService {
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
private readonly authMailService: AuthMailService,
private readonly prismaService: PrismaService
private readonly prismaService: PrismaService,
private readonly dataEncryptionService: DataEncryptionService
) {}
async sendEmailCode(email: string): Promise<{ success: boolean; expiresInSeconds: number }> {
@@ -118,7 +120,10 @@ export class AuthService {
}
});
return this.issueTokens(entry.user);
return this.issueTokens({
id: entry.user.id,
email: this.readRequiredEmail(entry.user.email)
});
}
async revokeRefreshToken(refreshToken: string): Promise<{ success: boolean }> {
@@ -205,19 +210,27 @@ export class AuthService {
}
private async getOrCreateUser(email: string): Promise<AuthUser> {
return this.prismaService.user.upsert({
const normalizedEmail = email.toLowerCase();
const emailHash = this.dataEncryptionService.createLookupHash("user.email", normalizedEmail);
const user = await this.prismaService.user.upsert({
where: {
email
emailHash
},
update: {},
create: {
email
email: this.encryptRequiredString(normalizedEmail),
emailHash
},
select: {
id: true,
email: true
}
});
return {
id: user.id,
email: this.readRequiredEmail(user.email)
};
}
private generateCode(): string {
@@ -254,4 +267,22 @@ export class AuthService {
user
};
}
private encryptRequiredString(value: string): string {
const encryptedValue = this.dataEncryptionService.encryptString(value);
if (!encryptedValue) {
throw new UnauthorizedException("用户敏感字段加密失败");
}
return encryptedValue;
}
private readRequiredEmail(value: string): string {
const decryptedValue = this.dataEncryptionService.decryptString(value);
if (typeof decryptedValue !== "string" || decryptedValue.length === 0) {
throw new UnauthorizedException("用户邮箱解密失败");
}
return decryptedValue;
}
}
@@ -1,7 +1,7 @@
import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Prisma } from "../../generated/prisma/client";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
const ENCRYPTION_PREFIX = "encv1";
const ENCRYPTION_ALGORITHM = "aes-256-gcm";
@@ -122,6 +122,22 @@ export class DataEncryptionService {
return JSON.stringify(value);
}
createLookupHash(scope: string, value: string): string {
const normalizedScope = scope.trim().toLowerCase();
if (!normalizedScope) {
throw new InternalServerErrorException("缺少盲索引作用域");
}
const secret = this.configService.get<string>("DATA_ENCRYPTION_SECRET");
if (!secret) {
throw new InternalServerErrorException("服务端未配置 DATA_ENCRYPTION_SECRET,无法生成盲索引");
}
return createHmac("sha256", `lookup:${normalizedScope}:${secret}`)
.update(value, "utf8")
.digest("hex");
}
private isEncryptedPayload(value: string): boolean {
return this.isEncryptedString(value);
}