You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

306 lines
9.5 KiB

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 };
}