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