diff --git a/server/services/auth.ts b/server/services/auth.ts new file mode 100644 index 0000000..4c4089b --- /dev/null +++ b/server/services/auth.ts @@ -0,0 +1,306 @@ +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 }; +}