206 lines
5.3 KiB
TypeScript
206 lines
5.3 KiB
TypeScript
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
|
|
|
|
import { cookies } from "next/headers";
|
|
|
|
import type { Account, AuthContext, Permission, PublicAccount, RoleId, Session } from "@/modules/core/types";
|
|
import { jsonFailure } from "@/modules/core/server/api";
|
|
import { recordAuditLog } from "@/modules/core/server/audit";
|
|
import { getPermissionsForRole, hasPermission } from "@/modules/core/server/permissions";
|
|
import { readData, updateData } from "@/modules/core/server/store";
|
|
|
|
const SESSION_COOKIE = "teatea_session";
|
|
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7;
|
|
|
|
export function normalizeEmail(email: string): string {
|
|
return email.trim().toLowerCase();
|
|
}
|
|
|
|
export function toPublicAccount(account: Account): PublicAccount {
|
|
return {
|
|
id: account.id,
|
|
name: account.name,
|
|
email: account.email,
|
|
role: account.role,
|
|
organization: account.organization,
|
|
status: account.status,
|
|
createdAt: account.createdAt,
|
|
updatedAt: 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: Account): 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);
|
|
}
|
|
|
|
export function createAccountRecord(input: {
|
|
name: string;
|
|
email: string;
|
|
password: string;
|
|
role: RoleId;
|
|
organization?: string;
|
|
}): Account {
|
|
const now = new Date().toISOString();
|
|
const password = hashPassword(input.password);
|
|
|
|
return {
|
|
id: randomUUID(),
|
|
name: input.name.trim(),
|
|
email: normalizeEmail(input.email),
|
|
role: input.role,
|
|
organization: input.organization?.trim(),
|
|
passwordHash: password.hash,
|
|
passwordSalt: password.salt,
|
|
status: "active",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
|
|
export function createSessionRecord(accountId: string): Session {
|
|
const now = new Date();
|
|
const expiresAt = new Date(now.getTime() + SESSION_MAX_AGE_SECONDS * 1000);
|
|
|
|
return {
|
|
id: randomUUID(),
|
|
accountId,
|
|
createdAt: now.toISOString(),
|
|
expiresAt: expiresAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
export async function setSessionCookie(sessionId: string): Promise<void> {
|
|
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<void> {
|
|
const cookieStore = await cookies();
|
|
cookieStore.delete(SESSION_COOKIE);
|
|
}
|
|
|
|
export async function getBootstrapState(): Promise<{ setupRequired: boolean }> {
|
|
const data = await readData();
|
|
return { setupRequired: data.accounts.length === 0 };
|
|
}
|
|
|
|
export async function getCurrentAuthContext(): Promise<AuthContext | null> {
|
|
const cookieStore = await cookies();
|
|
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
|
|
|
|
if (!sessionId) {
|
|
return null;
|
|
}
|
|
|
|
const data = await readData();
|
|
const session = data.sessions.find((item) => item.id === sessionId);
|
|
if (!session || Date.parse(session.expiresAt) <= Date.now()) {
|
|
return null;
|
|
}
|
|
|
|
const account = data.accounts.find((item) => item.id === session.accountId);
|
|
if (!account || account.status !== "active") {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
account: toPublicAccount(account),
|
|
permissions: getPermissionsForRole(account.role),
|
|
session,
|
|
};
|
|
}
|
|
|
|
export async function removeCurrentSession(): Promise<PublicAccount | null> {
|
|
const context = await getCurrentAuthContext();
|
|
const cookieStore = await cookies();
|
|
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
|
|
|
|
if (sessionId) {
|
|
await updateData((data) => {
|
|
data.sessions = data.sessions.filter((item) => item.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;
|
|
};
|
|
|
|
export async function requirePermission(
|
|
permission: Permission,
|
|
auditContext: DeniedAuditContext,
|
|
): Promise<PermissionCheckSuccess | PermissionCheckFailure> {
|
|
const context = await getCurrentAuthContext();
|
|
|
|
if (!context) {
|
|
await recordAuditLog({
|
|
action: auditContext.action,
|
|
targetType: auditContext.targetType,
|
|
targetId: auditContext.targetId,
|
|
result: "denied",
|
|
reason: "未登录或会话已过期",
|
|
});
|
|
|
|
return {
|
|
success: false,
|
|
response: jsonFailure("未登录或会话已过期", 401),
|
|
};
|
|
}
|
|
|
|
if (!hasPermission(context.account.role, permission)) {
|
|
await recordAuditLog({
|
|
actor: context.account,
|
|
action: auditContext.action,
|
|
targetType: auditContext.targetType,
|
|
targetId: auditContext.targetId,
|
|
result: "denied",
|
|
reason: `缺少权限:${permission}`,
|
|
});
|
|
|
|
return {
|
|
success: false,
|
|
response: jsonFailure("权限不足", 403),
|
|
};
|
|
}
|
|
|
|
return { success: true, context };
|
|
}
|