import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; import { and, eq, gt, isNull } from "drizzle-orm"; import { cookies } from "next/headers"; import { jsonFailure } from "@/modules/core/server/api"; import { recordAuditLog } from "@/modules/core/server/audit"; import { getDatabase } from "@/modules/core/server/db"; import { getPermissionsForRole, hasPermission, seedOrganizationRoles } from "@/modules/core/server/permissions"; import { accounts, joinRequests, memberships, organizations, roles, sessions } from "@/modules/core/server/schema"; import type { Account, AuthContext, Membership, Organization, Permission, PublicAccount, Session } from "@/modules/core/types"; const SESSION_COOKIE = "teatea_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; type AccountRow = typeof accounts.$inferSelect; type OrganizationRow = typeof organizations.$inferSelect; type MembershipRow = typeof memberships.$inferSelect; type RoleRow = typeof roles.$inferSelect; type SessionRow = typeof sessions.$inferSelect; export function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } function toIsoString(value: Date): string { return value.toISOString(); } function toOrganization(row: OrganizationRow): Organization { return { id: row.id, name: row.name, slug: row.slug, status: row.status, createdAt: toIsoString(row.createdAt), updatedAt: toIsoString(row.updatedAt), }; } function toSession(row: SessionRow): Session { return { id: row.id, accountId: row.accountId, activeOrganizationId: row.activeOrganizationId ?? undefined, createdAt: toIsoString(row.createdAt), expiresAt: toIsoString(row.expiresAt), }; } function toMembership(row: MembershipRow, role: RoleRow): Membership { return { id: row.id, accountId: row.accountId, organizationId: row.organizationId, roleId: row.roleId, roleKey: role.key, roleLabel: role.label, status: row.status, createdAt: toIsoString(row.createdAt), updatedAt: toIsoString(row.updatedAt), }; } export function toPublicAccount(account: AccountRow | Account, roleKey?: string, organization?: Organization): PublicAccount { const fallbackRole = "role" in account ? account.role : "viewer"; const fallbackOrganization = "organization" in account ? account.organization : undefined; const fallbackOrganizationId = "organizationId" in account ? account.organizationId : undefined; return { id: account.id, platformRoleId: "platformRoleId" in account ? account.platformRoleId ?? undefined : undefined, name: account.name, email: account.email, role: roleKey ?? fallbackRole, organization: organization?.name ?? fallbackOrganization, organizationId: organization?.id ?? fallbackOrganizationId, status: account.status, createdAt: typeof account.createdAt === "string" ? account.createdAt : toIsoString(account.createdAt), updatedAt: typeof account.updatedAt === "string" ? account.updatedAt : toIsoString(account.updatedAt), }; } export function hashPassword(password: string, salt = randomBytes(16).toString("hex")): { hash: string; salt: string; } { return { hash: scryptSync(password, salt, 64).toString("hex"), salt, }; } export function verifyPassword(password: string, account: Pick): boolean { const candidate = Buffer.from(hashPassword(password, account.passwordSalt).hash, "hex"); const stored = Buffer.from(account.passwordHash, "hex"); if (candidate.length !== stored.length) { return false; } return timingSafeEqual(candidate, stored); } function createSlug(name: string): string { const base = name .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); return base.length > 0 ? base : `org-${Date.now()}`; } export async function setSessionCookie(sessionId: string): Promise { const cookieStore = await cookies(); cookieStore.set({ name: SESSION_COOKIE, value: sessionId, httpOnly: true, sameSite: "lax", path: "/", maxAge: SESSION_MAX_AGE_SECONDS, }); } export async function clearSessionCookie(): Promise { const cookieStore = await cookies(); cookieStore.delete(SESSION_COOKIE); } export async function getBootstrapState(): Promise<{ setupRequired: boolean }> { const database = getDatabase(); const existingAccounts = await database.select({ id: accounts.id }).from(accounts).limit(1); return { setupRequired: existingAccounts.length === 0 }; } export async function setupFirstPlatformAdmin(input: { organization: string; name: string; email: string; password: string; }): Promise<{ account: PublicAccount; session: Session }> { const database = getDatabase(); const password = hashPassword(input.password); const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000); const created = await database.transaction(async (transaction) => { const existingAccounts = await transaction.select({ id: accounts.id }).from(accounts).limit(1); if (existingAccounts.length > 0) { return null; } const platformRoleRows = await transaction .select() .from(roles) .where(and(eq(roles.key, "platform_admin"), isNull(roles.organizationId))) .limit(1); const platformRole = platformRoleRows[0]; if (!platformRole) { throw new Error("平台超管角色未初始化,请先运行数据库初始化"); } const organizationRows = await transaction .insert(organizations) .values({ name: input.organization.trim(), slug: createSlug(input.organization), }) .returning(); const organization = organizationRows[0]; if (!organization) { throw new Error("机构初始化失败"); } const accountRows = await transaction .insert(accounts) .values({ platformRoleId: platformRole.id, name: input.name.trim(), email: normalizeEmail(input.email), passwordHash: password.hash, passwordSalt: password.salt, status: "active", }) .returning(); const account = accountRows[0]; if (!account) { throw new Error("账号初始化失败"); } const sessionRows = await transaction .insert(sessions) .values({ accountId: account.id, activeOrganizationId: organization.id, expiresAt, }) .returning(); const session = sessionRows[0]; if (!session) { throw new Error("会话初始化失败"); } return { account, organization, platformRole, session }; }); if (!created) { throw new Error("系统已完成初始化"); } await seedOrganizationRoles(created.organization.id); const databaseAfterSeed = getDatabase(); const orgAdminRows = await databaseAfterSeed .select() .from(roles) .where(and(eq(roles.organizationId, created.organization.id), eq(roles.key, "org_admin"))) .limit(1); const orgAdminRole = orgAdminRows[0]; if (orgAdminRole) { await databaseAfterSeed .insert(memberships) .values({ accountId: created.account.id, organizationId: created.organization.id, roleId: orgAdminRole.id, status: "active", }) .onConflictDoNothing(); } const organization = toOrganization(created.organization); const publicAccount = toPublicAccount(created.account, created.platformRole.key, organization); const session = toSession(created.session); await recordAuditLog({ actor: publicAccount, organizationId: organization.id, action: "account.setup", targetType: "account", targetId: publicAccount.id, result: "success", reason: "初始化平台超管与首个机构", }); return { account: publicAccount, session }; } export async function createRegistration(input: { name: string; email: string; password: string; organizationId?: string; }): Promise<{ account: PublicAccount; session?: Session }> { const database = getDatabase(); const password = hashPassword(input.password); const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000); const created = await database.transaction(async (transaction) => { const normalizedEmail = normalizeEmail(input.email); const existing = await transaction.select({ id: accounts.id }).from(accounts).where(eq(accounts.email, normalizedEmail)); if (existing.length > 0) { return null; } const accountRows = await transaction .insert(accounts) .values({ name: input.name.trim(), email: normalizedEmail, passwordHash: password.hash, passwordSalt: password.salt, status: input.organizationId ? "pending" : "active", }) .returning(); const account = accountRows[0]; if (!account) { throw new Error("账号创建失败"); } const sessionRows = input.organizationId ? [] : await transaction .insert(sessions) .values({ accountId: account.id, expiresAt, }) .returning(); const session = sessionRows[0]; if (input.organizationId) { await transaction.insert(joinRequests).values({ accountId: account.id, organizationId: input.organizationId, status: "pending", reason: "公开注册申请加入机构", }); } return { account, session }; }); if (!created) { throw new Error("账号已存在"); } const publicAccount = toPublicAccount(created.account); const session = created.session ? toSession(created.session) : undefined; await recordAuditLog({ actor: publicAccount, organizationId: input.organizationId, action: "account.register", targetType: "account", targetId: publicAccount.id, result: "success", reason: input.organizationId ? "注册账号并申请加入机构" : "注册独立账号", }); return { account: publicAccount, session }; } export async function loginWithPassword(input: { email: string; password: string; }): Promise<{ account: PublicAccount; session: Session }> { const database = getDatabase(); const accountRows = await database .select() .from(accounts) .where(eq(accounts.email, normalizeEmail(input.email))) .limit(1); const account = accountRows[0]; if (!account || account.status !== "active" || !verifyPassword(input.password, account)) { throw new Error("邮箱或密码错误"); } const membershipRows = await database .select({ membership: memberships, organization: organizations, role: roles }) .from(memberships) .innerJoin(organizations, eq(organizations.id, memberships.organizationId)) .innerJoin(roles, eq(roles.id, memberships.roleId)) .where(and(eq(memberships.accountId, account.id), eq(memberships.status, "active"), eq(roles.isEnabled, true))) .limit(1); const membership = membershipRows[0]; const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000); const sessionRows = await database .insert(sessions) .values({ accountId: account.id, activeOrganizationId: membership?.organization.id, expiresAt, }) .returning(); const sessionRow = sessionRows[0]; if (!sessionRow) { throw new Error("会话创建失败"); } const platformRoleRows = account.platformRoleId ? await database.select().from(roles).where(eq(roles.id, account.platformRoleId)).limit(1) : []; const platformRole = platformRoleRows[0]; const organization = membership ? toOrganization(membership.organization) : undefined; const publicAccount = toPublicAccount(account, platformRole?.key ?? membership?.role.key, organization); const session = toSession(sessionRow); await recordAuditLog({ actor: publicAccount, organizationId: organization?.id, action: "account.login", targetType: "account", targetId: account.id, result: "success", reason: "账号登录", }); return { account: publicAccount, session }; } export async function getCurrentAuthContext(): Promise { const cookieStore = await cookies(); const sessionId = cookieStore.get(SESSION_COOKIE)?.value; if (!sessionId) { return null; } const database = getDatabase(); const rows = await database .select({ session: sessions, account: accounts }) .from(sessions) .innerJoin(accounts, eq(accounts.id, sessions.accountId)) .where(and(eq(sessions.id, sessionId), gt(sessions.expiresAt, new Date()))) .limit(1); const row = rows[0]; if (!row || row.account.status === "disabled") { return null; } const session = toSession(row.session); const platformRoleRows = row.account.platformRoleId ? await database.select().from(roles).where(and(eq(roles.id, row.account.platformRoleId), eq(roles.isEnabled, true))).limit(1) : []; const platformRole = platformRoleRows[0]; const organizationRows = row.session.activeOrganizationId ? await database.select().from(organizations).where(eq(organizations.id, row.session.activeOrganizationId)).limit(1) : []; const organizationRow = organizationRows[0]; const membershipRows = organizationRow ? await database .select({ membership: memberships, role: roles }) .from(memberships) .innerJoin(roles, eq(roles.id, memberships.roleId)) .where( and( eq(memberships.accountId, row.account.id), eq(memberships.organizationId, organizationRow.id), eq(memberships.status, "active"), eq(roles.isEnabled, true), ), ) .limit(1) : []; const membershipRow = membershipRows[0]; const platformPermissions = platformRole ? await getPermissionsForRole(platformRole.id) : []; const organizationPermissions = membershipRow ? await getPermissionsForRole(membershipRow.role.id) : []; const permissions = [...new Set([...platformPermissions, ...organizationPermissions])]; const organization = organizationRow ? toOrganization(organizationRow) : undefined; const membership = membershipRow ? toMembership(membershipRow.membership, membershipRow.role) : undefined; return { account: toPublicAccount(row.account, platformRole?.key ?? membership?.roleKey, organization), organization, membership, permissions, session, }; } export async function removeCurrentSession(): Promise { const context = await getCurrentAuthContext(); const cookieStore = await cookies(); const sessionId = cookieStore.get(SESSION_COOKIE)?.value; if (sessionId) { const database = getDatabase(); await database.delete(sessions).where(eq(sessions.id, sessionId)); } await clearSessionCookie(); return context?.account ?? null; } type PermissionCheckSuccess = { success: true; context: AuthContext; }; type PermissionCheckFailure = { success: false; response: Response; }; type DeniedAuditContext = { action: string; targetType: string; targetId?: string; organizationId?: string; }; export async function requirePermission( permission: Permission, auditContext: DeniedAuditContext, ): Promise { const context = await getCurrentAuthContext(); if (!context) { await recordAuditLog({ organizationId: auditContext.organizationId, action: auditContext.action, targetType: auditContext.targetType, targetId: auditContext.targetId, result: "denied", reason: "未登录或会话已过期", }); return { success: false, response: jsonFailure("未登录或会话已过期", 401), }; } if (!hasPermission(context.permissions, permission)) { await recordAuditLog({ actor: context.account, organizationId: auditContext.organizationId ?? context.organization?.id, action: auditContext.action, targetType: auditContext.targetType, targetId: auditContext.targetId, result: "denied", reason: `缺少权限:${permission}`, }); return { success: false, response: jsonFailure("权限不足", 403), }; } return { success: true, context }; }