From 4c6aeb3e6c88183a7e01a93d43fa869d47ed8878 Mon Sep 17 00:00:00 2001 From: Yaosanqi137 Date: Mon, 6 Apr 2026 15:55:27 +0800 Subject: [PATCH] feat(api-security): encrypt user fields and ai usage logs --- apps/api/package.json | 2 +- apps/api/prisma/schema.prisma | 5 +- apps/api/scripts/reencrypt-sensitive-data.ts | 213 ++++++++--- apps/api/src/ai/ai.service.ts | 12 +- apps/api/src/auth/auth.service.ts | 41 +- .../src/security/data-encryption.service.ts | 18 +- apps/api/test/ai.spec.ts | 14 +- apps/api/test/auth.spec.ts | 355 ++++++++++++++++++ apps/api/tsconfig.json | 2 +- 9 files changed, 595 insertions(+), 67 deletions(-) create mode 100644 apps/api/test/auth.spec.ts diff --git a/apps/api/package.json b/apps/api/package.json index 642f983..cac4457 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,7 +9,7 @@ "prebuild": "pnpm run prisma:generate", "pretypecheck": "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:dev": "ts-node-dev --respawn --transpile-only src/main.ts", "build": "tsc -p tsconfig.build.json", diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index facef68..b6cfa31 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -63,7 +63,8 @@ enum NotificationStatus { model User { id String @id @default(cuid()) - email String @unique + email String + emailHash String? @unique nickname String? avatarUrl String? status UserStatus @default(ACTIVE) @@ -97,11 +98,13 @@ model AuthIdentity { provider AuthProvider providerUserId String email String? + emailHash String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerUserId]) + @@index([emailHash]) @@index([userId]) @@map("auth_identities") } diff --git a/apps/api/scripts/reencrypt-sensitive-data.ts b/apps/api/scripts/reencrypt-sensitive-data.ts index 1b6e41b..af39197 100644 --- a/apps/api/scripts/reencrypt-sensitive-data.ts +++ b/apps/api/scripts/reencrypt-sensitive-data.ts @@ -5,7 +5,14 @@ import { Prisma, PrismaClient } from "../generated/prisma/client"; import { DataEncryptionService } from "../src/security/data-encryption.service"; type MigrationCounter = Record< - "aiBindings" | "publicPools" | "tasks" | "attachments" | "syncOperations", + | "users" + | "authIdentities" + | "aiBindings" + | "publicPools" + | "aiUsageLogs" + | "tasks" + | "attachments" + | "syncOperations", number >; @@ -28,6 +35,26 @@ function encryptStringIfNeeded( return dataEncryptionService.encryptString(value) ?? null; } +function assignRequiredEncryptedString, 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, K extends keyof T>( + target: T, + key: K, + value: string | null | undefined +): void { + if (value !== undefined) { + target[key] = value as T[K]; + } +} + function encryptJsonIfNeeded( value: Prisma.JsonValue | null, dataEncryptionService: DataEncryptionService @@ -45,6 +72,19 @@ function encryptJsonIfNeeded( | 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 { if (!process.env["DATABASE_URL"]) { throw new Error("缺少 DATABASE_URL,无法执行敏感数据迁移"); @@ -61,14 +101,96 @@ async function main(): Promise { }); const dataEncryptionService = createEncryptionService(); const counter: MigrationCounter = { + users: 0, + authIdentities: 0, aiBindings: 0, publicPools: 0, + aiUsageLogs: 0, tasks: 0, attachments: 0, syncOperations: 0 }; 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({ select: { id: true, @@ -90,24 +212,12 @@ async function main(): Promise { const endpoint = encryptStringIfNeeded(binding.endpoint, dataEncryptionService); const encryptedApiKey = encryptStringIfNeeded(binding.encryptedApiKey, dataEncryptionService); - if (providerName !== undefined) { - data.providerName = providerName; - } - if (model !== undefined) { - data.model = model; - } - if (configId !== undefined) { - data.configId = configId; - } - if (configName !== undefined) { - data.configName = configName; - } - if (endpoint !== undefined) { - data.endpoint = endpoint; - } - if (encryptedApiKey !== undefined) { - data.encryptedApiKey = encryptedApiKey; - } + assignRequiredEncryptedString(data, "providerName", providerName); + assignOptionalEncryptedString(data, "model", model); + assignOptionalEncryptedString(data, "configId", configId); + assignOptionalEncryptedString(data, "configName", configName); + assignOptionalEncryptedString(data, "endpoint", endpoint); + assignOptionalEncryptedString(data, "encryptedApiKey", encryptedApiKey); if (Object.keys(data).length === 0) { continue; @@ -142,18 +252,10 @@ async function main(): Promise { dataEncryptionService ); - if (providerName !== undefined) { - data.providerName = providerName; - } - if (model !== undefined) { - data.model = model; - } - if (endpoint !== undefined) { - data.endpoint = endpoint; - } - if (encryptedApiKey !== undefined) { - data.encryptedApiKey = encryptedApiKey; - } + assignOptionalEncryptedString(data, "providerName", providerName); + assignOptionalEncryptedString(data, "model", model); + assignOptionalEncryptedString(data, "endpoint", endpoint); + assignOptionalEncryptedString(data, "encryptedApiKey", encryptedApiKey); if (Object.keys(data).length === 0) { continue; @@ -168,6 +270,35 @@ async function main(): Promise { 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({ select: { id: true, @@ -183,15 +314,11 @@ async function main(): Promise { const contentJson = encryptJsonIfNeeded(task.contentJson, dataEncryptionService); const contentText = encryptStringIfNeeded(task.contentText, dataEncryptionService); - if (title !== undefined) { - data.title = title; - } + assignRequiredEncryptedString(data, "title", title); if (contentJson !== undefined) { data.contentJson = contentJson; } - if (contentText !== undefined) { - data.contentText = contentText; - } + assignOptionalEncryptedString(data, "contentText", contentText); if (Object.keys(data).length === 0) { continue; @@ -221,15 +348,9 @@ async function main(): Promise { const fileName = encryptStringIfNeeded(attachment.fileName, dataEncryptionService); const checksum = encryptStringIfNeeded(attachment.checksum, dataEncryptionService); - if (url !== undefined) { - data.url = url; - } - if (fileName !== undefined) { - data.fileName = fileName; - } - if (checksum !== undefined) { - data.checksum = checksum; - } + assignRequiredEncryptedString(data, "url", url); + assignOptionalEncryptedString(data, "fileName", fileName); + assignOptionalEncryptedString(data, "checksum", checksum); if (Object.keys(data).length === 0) { continue; diff --git a/apps/api/src/ai/ai.service.ts b/apps/api/src/ai/ai.service.ts index cbeef1d..484837c 100644 --- a/apps/api/src/ai/ai.service.ts +++ b/apps/api/src/ai/ai.service.ts @@ -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, diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 52bac56..8554a5c 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { - 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; + } } diff --git a/apps/api/src/security/data-encryption.service.ts b/apps/api/src/security/data-encryption.service.ts index 2af16fd..ece7ceb 100644 --- a/apps/api/src/security/data-encryption.service.ts +++ b/apps/api/src/security/data-encryption.service.ts @@ -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("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); } diff --git a/apps/api/test/ai.spec.ts b/apps/api/test/ai.spec.ts index d387db5..f3a9478 100644 --- a/apps/api/test/ai.spec.ts +++ b/apps/api/test/ai.spec.ts @@ -612,12 +612,10 @@ describe("AiController (integration)", () => { } ]); expect(prismaService.getUsageLogs()).toEqual([ - { + expect.objectContaining({ id: expect.any(String), userId: "user_1", channel: AiChannel.USER_KEY, - providerName: "openai", - model: "gpt-4o-mini", promptTokens: 0, completionTokens: 0, totalTokens: 0, @@ -625,13 +623,11 @@ describe("AiController (integration)", () => { success: false, errorCode: "UPSTREAM_UNREACHABLE", createdAt: expect.any(Date) - }, - { + }), + expect.objectContaining({ id: expect.any(String), userId: "user_1", channel: AiChannel.ASTRBOT, - providerName: "default", - model: null, promptTokens: 12, completionTokens: 8, totalTokens: 20, @@ -639,8 +635,10 @@ describe("AiController (integration)", () => { success: true, errorCode: null, 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 () => { diff --git a/apps/api/test/auth.spec.ts b/apps/api/test/auth.spec.ts new file mode 100644 index 0000000..8f1f1ae --- /dev/null +++ b/apps/api/test/auth.spec.ts @@ -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; + 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 { + 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) => + `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); + }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 17d29bc..f196337 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -5,6 +5,6 @@ "rootDir": ".", "outDir": "dist" }, - "include": ["src/**/*.ts", "generated/prisma/**/*.ts"], + "include": ["src/**/*.ts", "scripts/**/*.ts", "generated/prisma/**/*.ts"], "exclude": ["dist", "node_modules"] }