1 changed files with 306 additions and 0 deletions
@ -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<string, unknown> = { 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 }; |
|||
} |
|||
Loading…
Reference in new issue