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
+1 -1
View File
@@ -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",
+4 -1
View File
@@ -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")
} }
+167 -46
View File
@@ -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;
+8 -4
View File
@@ -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,
+36 -5
View File
@@ -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);
} }
+6 -8
View File
@@ -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 () => {
+355
View File
@@ -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);
});
});
+1 -1
View File
@@ -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"]
} }