feat(api-security): encrypt user fields and ai usage logs
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
"prebuild": "pnpm run prisma:generate",
|
"prebuild": "pnpm run prisma:generate",
|
||||||
"pretypecheck": "pnpm run prisma:generate",
|
"pretypecheck": "pnpm run prisma:generate",
|
||||||
"pretest": "pnpm run prisma:generate",
|
"pretest": "pnpm run prisma:generate",
|
||||||
"data:reencrypt": "ts-node scripts/reencrypt-sensitive-data.ts",
|
"data:reencrypt": "node -e \"require('node:fs').rmSync('.tmp-compile', { recursive: true, force: true })\" && tsc -p tsconfig.json --outDir .tmp-compile --noEmit false && node .tmp-compile/scripts/reencrypt-sensitive-data.js && node -e \"require('node:fs').rmSync('.tmp-compile', { recursive: true, force: true })\"",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"start:dev": "ts-node-dev --respawn --transpile-only src/main.ts",
|
"start:dev": "ts-node-dev --respawn --transpile-only src/main.ts",
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ enum NotificationStatus {
|
|||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String
|
||||||
|
emailHash String? @unique
|
||||||
nickname String?
|
nickname String?
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
status UserStatus @default(ACTIVE)
|
status UserStatus @default(ACTIVE)
|
||||||
@@ -97,11 +98,13 @@ model AuthIdentity {
|
|||||||
provider AuthProvider
|
provider AuthProvider
|
||||||
providerUserId String
|
providerUserId String
|
||||||
email String?
|
email String?
|
||||||
|
emailHash String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([provider, providerUserId])
|
@@unique([provider, providerUserId])
|
||||||
|
@@index([emailHash])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("auth_identities")
|
@@map("auth_identities")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { Prisma, PrismaClient } from "../generated/prisma/client";
|
|||||||
import { DataEncryptionService } from "../src/security/data-encryption.service";
|
import { DataEncryptionService } from "../src/security/data-encryption.service";
|
||||||
|
|
||||||
type MigrationCounter = Record<
|
type MigrationCounter = Record<
|
||||||
"aiBindings" | "publicPools" | "tasks" | "attachments" | "syncOperations",
|
| "users"
|
||||||
|
| "authIdentities"
|
||||||
|
| "aiBindings"
|
||||||
|
| "publicPools"
|
||||||
|
| "aiUsageLogs"
|
||||||
|
| "tasks"
|
||||||
|
| "attachments"
|
||||||
|
| "syncOperations",
|
||||||
number
|
number
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -28,6 +35,26 @@ function encryptStringIfNeeded(
|
|||||||
return dataEncryptionService.encryptString(value) ?? null;
|
return dataEncryptionService.encryptString(value) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assignRequiredEncryptedString<T extends Record<string, unknown>, K extends keyof T>(
|
||||||
|
target: T,
|
||||||
|
key: K,
|
||||||
|
value: string | null | undefined
|
||||||
|
): void {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
target[key] = value as T[K];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignOptionalEncryptedString<T extends Record<string, unknown>, K extends keyof T>(
|
||||||
|
target: T,
|
||||||
|
key: K,
|
||||||
|
value: string | null | undefined
|
||||||
|
): void {
|
||||||
|
if (value !== undefined) {
|
||||||
|
target[key] = value as T[K];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function encryptJsonIfNeeded(
|
function encryptJsonIfNeeded(
|
||||||
value: Prisma.JsonValue | null,
|
value: Prisma.JsonValue | null,
|
||||||
dataEncryptionService: DataEncryptionService
|
dataEncryptionService: DataEncryptionService
|
||||||
@@ -45,6 +72,19 @@ function encryptJsonIfNeeded(
|
|||||||
| Prisma.NullableJsonNullValueInput;
|
| Prisma.NullableJsonNullValueInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePlainString(
|
||||||
|
value: string | null,
|
||||||
|
dataEncryptionService: DataEncryptionService
|
||||||
|
): string | null {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataEncryptionService.isEncryptedString(value)
|
||||||
|
? (dataEncryptionService.decryptString(value) ?? null)
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
if (!process.env["DATABASE_URL"]) {
|
if (!process.env["DATABASE_URL"]) {
|
||||||
throw new Error("缺少 DATABASE_URL,无法执行敏感数据迁移");
|
throw new Error("缺少 DATABASE_URL,无法执行敏感数据迁移");
|
||||||
@@ -61,14 +101,96 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
const dataEncryptionService = createEncryptionService();
|
const dataEncryptionService = createEncryptionService();
|
||||||
const counter: MigrationCounter = {
|
const counter: MigrationCounter = {
|
||||||
|
users: 0,
|
||||||
|
authIdentities: 0,
|
||||||
aiBindings: 0,
|
aiBindings: 0,
|
||||||
publicPools: 0,
|
publicPools: 0,
|
||||||
|
aiUsageLogs: 0,
|
||||||
tasks: 0,
|
tasks: 0,
|
||||||
attachments: 0,
|
attachments: 0,
|
||||||
syncOperations: 0
|
syncOperations: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
emailHash: true,
|
||||||
|
nickname: true,
|
||||||
|
avatarUrl: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const normalizedEmail = resolvePlainString(user.email, dataEncryptionService)?.toLowerCase();
|
||||||
|
if (!normalizedEmail) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextEmailHash = dataEncryptionService.createLookupHash("user.email", normalizedEmail);
|
||||||
|
const data: Prisma.UserUpdateInput = {};
|
||||||
|
const email = encryptStringIfNeeded(user.email, dataEncryptionService);
|
||||||
|
const nickname = encryptStringIfNeeded(user.nickname, dataEncryptionService);
|
||||||
|
const avatarUrl = encryptStringIfNeeded(user.avatarUrl, dataEncryptionService);
|
||||||
|
|
||||||
|
assignRequiredEncryptedString(data, "email", email);
|
||||||
|
if (user.emailHash !== nextEmailHash) {
|
||||||
|
data.emailHash = nextEmailHash;
|
||||||
|
}
|
||||||
|
assignOptionalEncryptedString(data, "nickname", nickname);
|
||||||
|
assignOptionalEncryptedString(data, "avatarUrl", avatarUrl);
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id
|
||||||
|
},
|
||||||
|
data
|
||||||
|
});
|
||||||
|
counter.users += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authIdentities = await prisma.authIdentity.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
emailHash: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const authIdentity of authIdentities) {
|
||||||
|
const data: Prisma.AuthIdentityUpdateInput = {};
|
||||||
|
const email = encryptStringIfNeeded(authIdentity.email, dataEncryptionService);
|
||||||
|
const normalizedIdentityEmail = resolvePlainString(authIdentity.email, dataEncryptionService);
|
||||||
|
const nextEmailHash =
|
||||||
|
normalizedIdentityEmail === null
|
||||||
|
? null
|
||||||
|
: dataEncryptionService.createLookupHash(
|
||||||
|
"auth_identity.email",
|
||||||
|
normalizedIdentityEmail.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
assignOptionalEncryptedString(data, "email", email);
|
||||||
|
if (authIdentity.emailHash !== nextEmailHash) {
|
||||||
|
data.emailHash = nextEmailHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.authIdentity.update({
|
||||||
|
where: {
|
||||||
|
id: authIdentity.id
|
||||||
|
},
|
||||||
|
data
|
||||||
|
});
|
||||||
|
counter.authIdentities += 1;
|
||||||
|
}
|
||||||
|
|
||||||
const aiBindings = await prisma.aiProviderBinding.findMany({
|
const aiBindings = await prisma.aiProviderBinding.findMany({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -90,24 +212,12 @@ async function main(): Promise<void> {
|
|||||||
const endpoint = encryptStringIfNeeded(binding.endpoint, dataEncryptionService);
|
const endpoint = encryptStringIfNeeded(binding.endpoint, dataEncryptionService);
|
||||||
const encryptedApiKey = encryptStringIfNeeded(binding.encryptedApiKey, dataEncryptionService);
|
const encryptedApiKey = encryptStringIfNeeded(binding.encryptedApiKey, dataEncryptionService);
|
||||||
|
|
||||||
if (providerName !== undefined) {
|
assignRequiredEncryptedString(data, "providerName", providerName);
|
||||||
data.providerName = providerName;
|
assignOptionalEncryptedString(data, "model", model);
|
||||||
}
|
assignOptionalEncryptedString(data, "configId", configId);
|
||||||
if (model !== undefined) {
|
assignOptionalEncryptedString(data, "configName", configName);
|
||||||
data.model = model;
|
assignOptionalEncryptedString(data, "endpoint", endpoint);
|
||||||
}
|
assignOptionalEncryptedString(data, "encryptedApiKey", encryptedApiKey);
|
||||||
if (configId !== undefined) {
|
|
||||||
data.configId = configId;
|
|
||||||
}
|
|
||||||
if (configName !== undefined) {
|
|
||||||
data.configName = configName;
|
|
||||||
}
|
|
||||||
if (endpoint !== undefined) {
|
|
||||||
data.endpoint = endpoint;
|
|
||||||
}
|
|
||||||
if (encryptedApiKey !== undefined) {
|
|
||||||
data.encryptedApiKey = encryptedApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(data).length === 0) {
|
if (Object.keys(data).length === 0) {
|
||||||
continue;
|
continue;
|
||||||
@@ -142,18 +252,10 @@ async function main(): Promise<void> {
|
|||||||
dataEncryptionService
|
dataEncryptionService
|
||||||
);
|
);
|
||||||
|
|
||||||
if (providerName !== undefined) {
|
assignOptionalEncryptedString(data, "providerName", providerName);
|
||||||
data.providerName = providerName;
|
assignOptionalEncryptedString(data, "model", model);
|
||||||
}
|
assignOptionalEncryptedString(data, "endpoint", endpoint);
|
||||||
if (model !== undefined) {
|
assignOptionalEncryptedString(data, "encryptedApiKey", encryptedApiKey);
|
||||||
data.model = model;
|
|
||||||
}
|
|
||||||
if (endpoint !== undefined) {
|
|
||||||
data.endpoint = endpoint;
|
|
||||||
}
|
|
||||||
if (encryptedApiKey !== undefined) {
|
|
||||||
data.encryptedApiKey = encryptedApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(data).length === 0) {
|
if (Object.keys(data).length === 0) {
|
||||||
continue;
|
continue;
|
||||||
@@ -168,6 +270,35 @@ async function main(): Promise<void> {
|
|||||||
counter.publicPools += 1;
|
counter.publicPools += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const aiUsageLogs = await prisma.aiUsageLog.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
providerName: true,
|
||||||
|
model: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const aiUsageLog of aiUsageLogs) {
|
||||||
|
const data: Prisma.AiUsageLogUpdateInput = {};
|
||||||
|
const providerName = encryptStringIfNeeded(aiUsageLog.providerName, dataEncryptionService);
|
||||||
|
const model = encryptStringIfNeeded(aiUsageLog.model, dataEncryptionService);
|
||||||
|
|
||||||
|
assignOptionalEncryptedString(data, "providerName", providerName);
|
||||||
|
assignOptionalEncryptedString(data, "model", model);
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.aiUsageLog.update({
|
||||||
|
where: {
|
||||||
|
id: aiUsageLog.id
|
||||||
|
},
|
||||||
|
data
|
||||||
|
});
|
||||||
|
counter.aiUsageLogs += 1;
|
||||||
|
}
|
||||||
|
|
||||||
const tasks = await prisma.task.findMany({
|
const tasks = await prisma.task.findMany({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -183,15 +314,11 @@ async function main(): Promise<void> {
|
|||||||
const contentJson = encryptJsonIfNeeded(task.contentJson, dataEncryptionService);
|
const contentJson = encryptJsonIfNeeded(task.contentJson, dataEncryptionService);
|
||||||
const contentText = encryptStringIfNeeded(task.contentText, dataEncryptionService);
|
const contentText = encryptStringIfNeeded(task.contentText, dataEncryptionService);
|
||||||
|
|
||||||
if (title !== undefined) {
|
assignRequiredEncryptedString(data, "title", title);
|
||||||
data.title = title;
|
|
||||||
}
|
|
||||||
if (contentJson !== undefined) {
|
if (contentJson !== undefined) {
|
||||||
data.contentJson = contentJson;
|
data.contentJson = contentJson;
|
||||||
}
|
}
|
||||||
if (contentText !== undefined) {
|
assignOptionalEncryptedString(data, "contentText", contentText);
|
||||||
data.contentText = contentText;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(data).length === 0) {
|
if (Object.keys(data).length === 0) {
|
||||||
continue;
|
continue;
|
||||||
@@ -221,15 +348,9 @@ async function main(): Promise<void> {
|
|||||||
const fileName = encryptStringIfNeeded(attachment.fileName, dataEncryptionService);
|
const fileName = encryptStringIfNeeded(attachment.fileName, dataEncryptionService);
|
||||||
const checksum = encryptStringIfNeeded(attachment.checksum, dataEncryptionService);
|
const checksum = encryptStringIfNeeded(attachment.checksum, dataEncryptionService);
|
||||||
|
|
||||||
if (url !== undefined) {
|
assignRequiredEncryptedString(data, "url", url);
|
||||||
data.url = url;
|
assignOptionalEncryptedString(data, "fileName", fileName);
|
||||||
}
|
assignOptionalEncryptedString(data, "checksum", checksum);
|
||||||
if (fileName !== undefined) {
|
|
||||||
data.fileName = fileName;
|
|
||||||
}
|
|
||||||
if (checksum !== undefined) {
|
|
||||||
data.checksum = checksum;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(data).length === 0) {
|
if (Object.keys(data).length === 0) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -464,8 +464,8 @@ export class AiService {
|
|||||||
return {
|
return {
|
||||||
id: log.id,
|
id: log.id,
|
||||||
channel: log.channel,
|
channel: log.channel,
|
||||||
providerName: log.providerName,
|
providerName: this.readDecryptedString(log.providerName),
|
||||||
model: log.model,
|
model: this.readDecryptedString(log.model),
|
||||||
promptTokens: log.promptTokens,
|
promptTokens: log.promptTokens,
|
||||||
completionTokens: log.completionTokens,
|
completionTokens: log.completionTokens,
|
||||||
totalTokens: log.totalTokens,
|
totalTokens: log.totalTokens,
|
||||||
@@ -730,8 +730,12 @@ export class AiService {
|
|||||||
data: {
|
data: {
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
channel: input.channel,
|
channel: input.channel,
|
||||||
providerName: input.providerName,
|
providerName:
|
||||||
model: input.model,
|
input.providerName === null
|
||||||
|
? null
|
||||||
|
: this.dataEncryptionService.encryptString(input.providerName),
|
||||||
|
model:
|
||||||
|
input.model === null ? null : this.dataEncryptionService.encryptString(input.model),
|
||||||
promptTokens: input.usage?.promptTokens ?? 0,
|
promptTokens: input.usage?.promptTokens ?? 0,
|
||||||
completionTokens: input.usage?.completionTokens ?? 0,
|
completionTokens: input.usage?.completionTokens ?? 0,
|
||||||
totalTokens: input.usage?.totalTokens ?? 0,
|
totalTokens: input.usage?.totalTokens ?? 0,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { authenticator } from "@otplib/preset-default";
|
import { authenticator } from "@otplib/preset-default";
|
||||||
import { AuthMailService } from "./auth-mail.service";
|
import { AuthMailService } from "./auth-mail.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { DataEncryptionService } from "../security/data-encryption.service";
|
||||||
|
|
||||||
type EmailCodeEntry = {
|
type EmailCodeEntry = {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -33,7 +34,8 @@ export class AuthService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly authMailService: AuthMailService,
|
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 }> {
|
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 }> {
|
async revokeRefreshToken(refreshToken: string): Promise<{ success: boolean }> {
|
||||||
@@ -205,19 +210,27 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getOrCreateUser(email: string): Promise<AuthUser> {
|
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: {
|
where: {
|
||||||
email
|
emailHash
|
||||||
},
|
},
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
email
|
email: this.encryptRequiredString(normalizedEmail),
|
||||||
|
emailHash
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true
|
email: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: this.readRequiredEmail(user.email)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateCode(): string {
|
private generateCode(): string {
|
||||||
@@ -254,4 +267,22 @@ export class AuthService {
|
|||||||
user
|
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 { Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Prisma } from "../../generated/prisma/client";
|
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_PREFIX = "encv1";
|
||||||
const ENCRYPTION_ALGORITHM = "aes-256-gcm";
|
const ENCRYPTION_ALGORITHM = "aes-256-gcm";
|
||||||
@@ -122,6 +122,22 @@ export class DataEncryptionService {
|
|||||||
return JSON.stringify(value);
|
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 {
|
private isEncryptedPayload(value: string): boolean {
|
||||||
return this.isEncryptedString(value);
|
return this.isEncryptedString(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -612,12 +612,10 @@ describe("AiController (integration)", () => {
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
expect(prismaService.getUsageLogs()).toEqual([
|
expect(prismaService.getUsageLogs()).toEqual([
|
||||||
{
|
expect.objectContaining({
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
channel: AiChannel.USER_KEY,
|
channel: AiChannel.USER_KEY,
|
||||||
providerName: "openai",
|
|
||||||
model: "gpt-4o-mini",
|
|
||||||
promptTokens: 0,
|
promptTokens: 0,
|
||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
@@ -625,13 +623,11 @@ describe("AiController (integration)", () => {
|
|||||||
success: false,
|
success: false,
|
||||||
errorCode: "UPSTREAM_UNREACHABLE",
|
errorCode: "UPSTREAM_UNREACHABLE",
|
||||||
createdAt: expect.any(Date)
|
createdAt: expect.any(Date)
|
||||||
},
|
}),
|
||||||
{
|
expect.objectContaining({
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
channel: AiChannel.ASTRBOT,
|
channel: AiChannel.ASTRBOT,
|
||||||
providerName: "default",
|
|
||||||
model: null,
|
|
||||||
promptTokens: 12,
|
promptTokens: 12,
|
||||||
completionTokens: 8,
|
completionTokens: 8,
|
||||||
totalTokens: 20,
|
totalTokens: 20,
|
||||||
@@ -639,8 +635,10 @@ describe("AiController (integration)", () => {
|
|||||||
success: true,
|
success: true,
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
createdAt: expect.any(Date)
|
createdAt: expect.any(Date)
|
||||||
}
|
})
|
||||||
]);
|
]);
|
||||||
|
expect(prismaService.getUsageLogs()[0]?.providerName).not.toBe("openai");
|
||||||
|
expect(prismaService.getUsageLogs()[0]?.model).not.toBe("gpt-4o-mini");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should allow astrbot binding with config id only", async () => {
|
it("should allow astrbot binding with config id only", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { AuthMailService } from "../src/auth/auth-mail.service";
|
||||||
|
import { AuthService } from "../src/auth/auth.service";
|
||||||
|
import { PrismaService } from "../src/prisma/prisma.service";
|
||||||
|
import { DataEncryptionService } from "../src/security/data-encryption.service";
|
||||||
|
|
||||||
|
type UserRecord = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
emailHash: string;
|
||||||
|
nickname: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RefreshTokenRecord = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
tokenHash: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
revokedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserSecurityRecord = {
|
||||||
|
userId: string;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
twoFactorSecret: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
class InMemoryAuthPrismaService {
|
||||||
|
private userIdSequence = 1;
|
||||||
|
private refreshTokenIdSequence = 1;
|
||||||
|
private users: UserRecord[] = [];
|
||||||
|
private refreshTokens: RefreshTokenRecord[] = [];
|
||||||
|
private userSecurities: UserSecurityRecord[] = [];
|
||||||
|
|
||||||
|
readonly user = {
|
||||||
|
upsert: async (args: {
|
||||||
|
where: {
|
||||||
|
emailHash: string;
|
||||||
|
};
|
||||||
|
update: Record<string, never>;
|
||||||
|
create: {
|
||||||
|
email: string;
|
||||||
|
emailHash: string;
|
||||||
|
};
|
||||||
|
select: {
|
||||||
|
id: true;
|
||||||
|
email: true;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const existingUser = this.users.find((user) => user.emailHash === args.where.emailHash);
|
||||||
|
if (existingUser) {
|
||||||
|
return {
|
||||||
|
id: existingUser.id,
|
||||||
|
email: existingUser.email
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdUser: UserRecord = {
|
||||||
|
id: `user_${this.userIdSequence++}`,
|
||||||
|
email: args.create.email,
|
||||||
|
emailHash: args.create.emailHash,
|
||||||
|
nickname: null,
|
||||||
|
avatarUrl: null
|
||||||
|
};
|
||||||
|
this.users.push(createdUser);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: createdUser.id,
|
||||||
|
email: createdUser.email
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly refreshToken = {
|
||||||
|
create: async (args: {
|
||||||
|
data: {
|
||||||
|
userId: string;
|
||||||
|
tokenHash: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const refreshToken: RefreshTokenRecord = {
|
||||||
|
id: `refresh_${this.refreshTokenIdSequence++}`,
|
||||||
|
userId: args.data.userId,
|
||||||
|
tokenHash: args.data.tokenHash,
|
||||||
|
expiresAt: args.data.expiresAt,
|
||||||
|
revokedAt: null,
|
||||||
|
createdAt: new Date()
|
||||||
|
};
|
||||||
|
this.refreshTokens.push(refreshToken);
|
||||||
|
return refreshToken;
|
||||||
|
},
|
||||||
|
|
||||||
|
findUnique: async (args: {
|
||||||
|
where: {
|
||||||
|
tokenHash: string;
|
||||||
|
};
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true;
|
||||||
|
email: true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const refreshToken = this.refreshTokens.find(
|
||||||
|
(item) => item.tokenHash === args.where.tokenHash
|
||||||
|
);
|
||||||
|
if (!refreshToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = this.users.find((item) => item.id === refreshToken.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("user not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (args: {
|
||||||
|
where: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
data: {
|
||||||
|
revokedAt: Date;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const refreshToken = this.refreshTokens.find((item) => item.id === args.where.id);
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error("refresh token not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken.revokedAt = args.data.revokedAt;
|
||||||
|
return refreshToken;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMany: async (args: {
|
||||||
|
where: {
|
||||||
|
tokenHash: string;
|
||||||
|
revokedAt: null;
|
||||||
|
};
|
||||||
|
data: {
|
||||||
|
revokedAt: Date;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
let count = 0;
|
||||||
|
for (const refreshToken of this.refreshTokens) {
|
||||||
|
if (refreshToken.tokenHash !== args.where.tokenHash || refreshToken.revokedAt !== null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken.revokedAt = args.data.revokedAt;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly userSecurity = {
|
||||||
|
upsert: async (args: {
|
||||||
|
where: {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
update: {
|
||||||
|
twoFactorSecret: string;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
};
|
||||||
|
create: {
|
||||||
|
userId: string;
|
||||||
|
twoFactorSecret: string;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const existingSecurity = this.userSecurities.find(
|
||||||
|
(item) => item.userId === args.where.userId
|
||||||
|
);
|
||||||
|
if (existingSecurity) {
|
||||||
|
existingSecurity.twoFactorSecret = args.update.twoFactorSecret;
|
||||||
|
existingSecurity.twoFactorEnabled = args.update.twoFactorEnabled;
|
||||||
|
return existingSecurity;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdSecurity: UserSecurityRecord = {
|
||||||
|
userId: args.create.userId,
|
||||||
|
twoFactorSecret: args.create.twoFactorSecret,
|
||||||
|
twoFactorEnabled: args.create.twoFactorEnabled
|
||||||
|
};
|
||||||
|
this.userSecurities.push(createdSecurity);
|
||||||
|
return createdSecurity;
|
||||||
|
},
|
||||||
|
|
||||||
|
findUnique: async (args: {
|
||||||
|
where: {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
select: {
|
||||||
|
twoFactorSecret: true;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const security = this.userSecurities.find((item) => item.userId === args.where.userId);
|
||||||
|
if (!security) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
twoFactorSecret: security.twoFactorSecret
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (args: {
|
||||||
|
where: {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
data: {
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const security = this.userSecurities.find((item) => item.userId === args.where.userId);
|
||||||
|
if (!security) {
|
||||||
|
throw new Error("user security not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
security.twoFactorEnabled = args.data.twoFactorEnabled;
|
||||||
|
return security;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getUsers(): UserRecord[] {
|
||||||
|
return [...this.users];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockAuthMailService {
|
||||||
|
readonly sentMessages: Array<{
|
||||||
|
email: string;
|
||||||
|
code: string;
|
||||||
|
ttlSeconds: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
async sendLoginCode(email: string, code: string, ttlSeconds: number): Promise<void> {
|
||||||
|
this.sentMessages.push({
|
||||||
|
email,
|
||||||
|
code,
|
||||||
|
ttlSeconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AuthService", () => {
|
||||||
|
let authService: AuthService;
|
||||||
|
let prismaService: InMemoryAuthPrismaService;
|
||||||
|
let authMailService: MockAuthMailService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prismaService = new InMemoryAuthPrismaService();
|
||||||
|
authMailService = new MockAuthMailService();
|
||||||
|
|
||||||
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
DataEncryptionService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: prismaService
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: AuthMailService,
|
||||||
|
useValue: authMailService
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: JwtService,
|
||||||
|
useValue: {
|
||||||
|
signAsync: async (payload: Record<string, unknown>) =>
|
||||||
|
`signed-${String(payload["sub"])}-${String(payload["email"])}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: (key: string) => {
|
||||||
|
switch (key) {
|
||||||
|
case "AUTH_EMAIL_CODE_TTL_SECONDS":
|
||||||
|
return "300";
|
||||||
|
case "AUTH_ACCESS_EXPIRES_IN_SECONDS":
|
||||||
|
return "900";
|
||||||
|
case "AUTH_REFRESH_EXPIRES_IN_SECONDS":
|
||||||
|
return "2592000";
|
||||||
|
case "AUTH_TOTP_ISSUER":
|
||||||
|
return "TodoList";
|
||||||
|
case "DATA_ENCRYPTION_SECRET":
|
||||||
|
return "test-data-encryption-secret";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
authService = moduleRef.get(AuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encrypt user email in database while keeping login flow available", async () => {
|
||||||
|
await authService.sendEmailCode("User@Example.com");
|
||||||
|
expect(authMailService.sentMessages).toHaveLength(1);
|
||||||
|
expect(authMailService.sentMessages[0]?.email).toBe("user@example.com");
|
||||||
|
|
||||||
|
const loginResult = await authService.loginWithEmailCode(
|
||||||
|
"USER@example.com",
|
||||||
|
authMailService.sentMessages[0]?.code ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loginResult.user.email).toBe("user@example.com");
|
||||||
|
expect(loginResult.accessToken).toContain("user@example.com");
|
||||||
|
|
||||||
|
const storedUser = prismaService.getUsers()[0];
|
||||||
|
expect(storedUser?.email).not.toBe("user@example.com");
|
||||||
|
expect(storedUser?.emailHash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decrypt user email when refreshing token", async () => {
|
||||||
|
await authService.sendEmailCode("refresh@example.com");
|
||||||
|
const loginResult = await authService.loginWithEmailCode(
|
||||||
|
"refresh@example.com",
|
||||||
|
authMailService.sentMessages[0]?.code ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshResult = await authService.refreshTokens(loginResult.refreshToken);
|
||||||
|
expect(refreshResult.user.email).toBe("refresh@example.com");
|
||||||
|
expect(refreshResult.accessToken).toContain("refresh@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid verification code", async () => {
|
||||||
|
await authService.sendEmailCode("invalid@example.com");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
authService.loginWithEmailCode("invalid@example.com", "000000")
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,6 @@
|
|||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "generated/prisma/**/*.ts"],
|
"include": ["src/**/*.ts", "scripts/**/*.ts", "generated/prisma/**/*.ts"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user