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