feat(api-security): encrypt user fields and ai usage logs
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user