feat(api-security): encrypt user fields and ai usage logs
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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<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(
|
||||
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<void> {
|
||||
if (!process.env["DATABASE_URL"]) {
|
||||
throw new Error("缺少 DATABASE_URL,无法执行敏感数据迁移");
|
||||
@@ -61,14 +101,96 @@ async function main(): Promise<void> {
|
||||
});
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts", "generated/prisma/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "scripts/**/*.ts", "generated/prisma/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user