import { randomBytes } from "node:crypto"; import type { H3Event } from "h3"; import { readBody, getRequestIP } from "h3"; import { and, eq, gt, isNull, sql } from "drizzle-orm"; import { dbGlobal } from "drizzle-pkg/lib/db"; import { authChallengesTable, usersTable, } from "drizzle-pkg/lib/schema/schema"; import log4js from "logger"; import { hashChallengeToken, randomUrlToken } from "../utils/challenge-token"; import { jsonError } from "../utils/errors"; import { noopMailer } from "../utils/mailer"; import { hashPassword, verifyPassword } from "../utils/password"; import { rateLimitOrThrow } from "../utils/rate-limit"; import { clearSessionCookie, readSessionIdFromCookie, writeSessionCookie, } from "../utils/session-cookie"; import { createSession, deleteSession, readSession } from "../utils/session-redis"; import { sessionTtlSeconds } from "../utils/session-ttl"; import { needsEmailVerified } from "../utils/verification-policy"; const logger = log4js.getLogger("AUTH"); function clientKey(event: H3Event): string { return ( getRequestIP(event) || (event.node.socket?.remoteAddress ?? "") || "unknown" ); } function publicUser(row: typeof usersTable.$inferSelect) { return { id: row.id, name: row.name, age: row.age, email: row.email, emailVerified: Boolean(row.emailVerifiedAt), }; } async function requireSessionUser(event: H3Event) { const sid = readSessionIdFromCookie(event); if (!sid) jsonError(401, "UNAUTHORIZED", "请先登录"); const sess = await readSession(sid); if (!sess) jsonError(401, "UNAUTHORIZED", "会话已失效"); const rows = await dbGlobal .select() .from(usersTable) .where(eq(usersTable.id, sess.userId)) .limit(1); const user = rows[0]; if (!user) { await deleteSession(sid); jsonError(401, "UNAUTHORIZED", "用户不存在"); } if (user.sessionVersion !== sess.sessionVersion) { await deleteSession(sid); jsonError(401, "UNAUTHORIZED", "会话已失效"); } return { user, sessionId: sid }; } export async function registerWithSession(event: H3Event) { await rateLimitOrThrow(`rl:register:${clientKey(event)}`, 30, 900); const body = await readBody<{ email?: string; password?: string; name?: string; age?: number; }>(event); const email = (body.email || "").trim().toLowerCase(); const password = body.password || ""; const name = (body.name || "").trim(); const age = body.age; if (!email.includes("@")) jsonError(400, "INVALID_EMAIL", "邮箱格式无效"); if (password.length < 8) jsonError(400, "WEAK_PASSWORD", "密码至少 8 位"); if (!name) jsonError(400, "INVALID_NAME", "姓名必填"); if (typeof age !== "number" || age < 1 || age > 150) { jsonError(400, "INVALID_AGE", "年龄无效"); } const dup = await dbGlobal .select({ id: usersTable.id }) .from(usersTable) .where(eq(usersTable.email, email)) .limit(1); if (dup.length) jsonError(409, "EMAIL_IN_USE", "该邮箱已注册"); const passwordHash = await hashPassword(password); const inserted = await dbGlobal .insert(usersTable) .values({ email, passwordHash, name, age }) .returning(); const user = inserted[0]; if (!user) jsonError(500, "REGISTER_FAILED", "注册失败"); const verifyToken = randomUrlToken(); await dbGlobal.insert(authChallengesTable).values({ userId: user.id, type: "email_verify", tokenHash: hashChallengeToken(verifyToken), expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }); await noopMailer.sendVerificationEmail({ to: email, token: verifyToken }); if (process.env.AUTH_DEBUG_LOG_TOKENS === "true") { logger.info(`verify email token (debug): ${verifyToken}`); } const oldSid = readSessionIdFromCookie(event); if (oldSid) await deleteSession(oldSid); const sessionId = randomBytes(32).toString("hex"); await createSession(sessionId, { userId: user.id, sessionVersion: user.sessionVersion, createdAt: new Date().toISOString(), }); writeSessionCookie(event, sessionId, sessionTtlSeconds()); const res: Record = { user: publicUser(user) }; if (process.env.NODE_ENV === "test") { res._testTokens = { verify: verifyToken }; } return res; } export async function loginWithSession( event: H3Event, input: { email: string; password: string }, ) { await rateLimitOrThrow(`rl:login:${clientKey(event)}`, 40, 900); const email = input.email.trim().toLowerCase(); const password = input.password; if (!email || !password) jsonError(400, "INVALID_INPUT", "邮箱与密码必填"); const rows = await dbGlobal .select() .from(usersTable) .where(eq(usersTable.email, email)) .limit(1); const user = rows[0]; if (!user || !(await verifyPassword(password, user.passwordHash))) { jsonError(401, "INVALID_CREDENTIALS", "邮箱或密码错误"); } const oldSid = readSessionIdFromCookie(event); if (oldSid) await deleteSession(oldSid); const sessionId = randomBytes(32).toString("hex"); await createSession(sessionId, { userId: user.id, sessionVersion: user.sessionVersion, createdAt: new Date().toISOString(), }); writeSessionCookie(event, sessionId, sessionTtlSeconds()); return { user: publicUser(user) }; } export async function logoutSession(event: H3Event) { const sid = readSessionIdFromCookie(event); if (sid) await deleteSession(sid); clearSessionCookie(event); return { ok: true as const }; } export async function getCurrentUser(event: H3Event) { const { user } = await requireSessionUser(event); return { user: publicUser(user) }; } export async function patchMe( event: H3Event, body: { name?: string; age?: number }, ) { const { user } = await requireSessionUser(event); if (needsEmailVerified("patch-me") && !user.emailVerifiedAt) { jsonError(403, "EMAIL_NOT_VERIFIED", "请先完成邮箱验证"); } const name = body.name !== undefined ? String(body.name).trim() : user.name; const age = body.age !== undefined ? body.age : user.age; if (!name) jsonError(400, "INVALID_NAME", "姓名必填"); if (typeof age !== "number" || age < 1 || age > 150) { jsonError(400, "INVALID_AGE", "年龄无效"); } const updatedRows = await dbGlobal .update(usersTable) .set({ name, age, updatedAt: new Date() }) .where(eq(usersTable.id, user.id)) .returning(); const updated = updatedRows[0]; if (!updated) jsonError(500, "UPDATE_FAILED", "更新失败"); return { user: publicUser(updated) }; } export async function forgotPassword(event: H3Event, emailRaw: string) { await rateLimitOrThrow(`rl:forgot:${clientKey(event)}`, 20, 3600); const email = emailRaw.trim().toLowerCase(); let resetToken: string | undefined; const rows = await dbGlobal .select() .from(usersTable) .where(eq(usersTable.email, email)) .limit(1); const user = rows[0]; if (user) { resetToken = randomUrlToken(); await dbGlobal.insert(authChallengesTable).values({ userId: user.id, type: "password_reset", tokenHash: hashChallengeToken(resetToken), expiresAt: new Date(Date.now() + 60 * 60 * 1000), }); await noopMailer.sendPasswordResetEmail({ to: email, token: resetToken }); if (process.env.AUTH_DEBUG_LOG_TOKENS === "true") { logger.info(`password reset token (debug): ${resetToken}`); } } const base = { ok: true as const }; if (process.env.NODE_ENV === "test" && resetToken) { return { ...base, _testTokens: { reset: resetToken } }; } return base; } export async function resetPassword( event: H3Event, input: { token: string; new_password: string }, ) { const token = input.token; const new_password = input.new_password; if (!token || new_password.length < 8) { jsonError(400, "INVALID_INPUT", "token 或新密码无效"); } const h = hashChallengeToken(token); const rows = await dbGlobal .select() .from(authChallengesTable) .where( and( eq(authChallengesTable.tokenHash, h), eq(authChallengesTable.type, "password_reset"), isNull(authChallengesTable.consumedAt), gt(authChallengesTable.expiresAt, new Date()), ), ) .limit(1); const ch = rows[0]; if (!ch) jsonError(400, "INVALID_TOKEN", "链接无效或已过期"); const passwordHash = await hashPassword(new_password); await dbGlobal .update(usersTable) .set({ passwordHash, sessionVersion: sql`${usersTable.sessionVersion} + 1`, updatedAt: new Date(), }) .where(eq(usersTable.id, ch.userId)); await dbGlobal .update(authChallengesTable) .set({ consumedAt: new Date() }) .where(eq(authChallengesTable.id, ch.id)); return { ok: true as const }; } export async function verifyEmail(event: H3Event, tokenRaw: string) { const token = tokenRaw.trim(); if (!token) jsonError(400, "INVALID_INPUT", "token 必填"); const h = hashChallengeToken(token); const rows = await dbGlobal .select() .from(authChallengesTable) .where( and( eq(authChallengesTable.tokenHash, h), eq(authChallengesTable.type, "email_verify"), isNull(authChallengesTable.consumedAt), gt(authChallengesTable.expiresAt, new Date()), ), ) .limit(1); const ch = rows[0]; if (!ch) jsonError(400, "INVALID_TOKEN", "链接无效或已过期"); await dbGlobal .update(usersTable) .set({ emailVerifiedAt: new Date(), updatedAt: new Date() }) .where(eq(usersTable.id, ch.userId)); await dbGlobal .update(authChallengesTable) .set({ consumedAt: new Date() }) .where(eq(authChallengesTable.id, ch.id)); return { ok: true as const }; }