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.
 
 
 
 

160 lines
4.5 KiB

import { dbGlobal } from "drizzle-pkg/lib/db";
import { users as usersTable, captchaCodes } from "drizzle-pkg/lib/schema/auth";
import { eq, and, lt, sql } from "drizzle-orm";
import { compare, hash } from "bcryptjs";
import log4js from "logger";
import { signToken, type JwtPayload } from "#server/utils/jwt";
import { generateCaptchaCode, generateCaptchaToken, generateCaptchaSvg, captchaSvgToDataUri } from "#server/utils/captcha";
const logger = log4js.getLogger("AUTH");
const CAPTCHA_EXPIRY_MS = 5 * 60 * 1000;
const BCRYPT_ROUNDS = 12;
// ========== Captcha ==========
export async function createCaptcha() {
const code = generateCaptchaCode();
const token = generateCaptchaToken();
const expiresAt = new Date(Date.now() + CAPTCHA_EXPIRY_MS);
await dbGlobal.insert(captchaCodes).values({
token,
code,
expiresAt,
});
const svg = generateCaptchaSvg(code);
const dataUri = captchaSvgToDataUri(svg);
return { token, image: dataUri };
}
async function verifyAndConsumeCaptcha(token: string, code: string): Promise<boolean> {
const now = new Date();
// Atomic: mark as used only if not already used and not expired, returning the code
const [record] = await dbGlobal
.update(captchaCodes)
.set({ used: true })
.where(
and(
eq(captchaCodes.token, token),
eq(captchaCodes.used, false),
sql`${captchaCodes.expiresAt} > ${now.getTime()}`,
),
)
.returning({ code: captchaCodes.code });
if (!record) return false;
return record.code.toUpperCase() === code.toUpperCase();
}
// ========== Register ==========
export async function registerUser(username: string, password: string, captchaToken: string, captchaCode: string) {
const validCaptcha = await verifyAndConsumeCaptcha(captchaToken, captchaCode);
if (!validCaptcha) {
return { success: false, message: "验证码错误或已过期" };
}
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
return { success: false, message: "用户名需为3-20位字母、数字或下划线" };
}
if (password.length < 8) {
return { success: false, message: "密码长度不能少于8位" };
}
const [existing] = await dbGlobal
.select({ id: usersTable.id })
.from(usersTable)
.where(eq(usersTable.username, username));
if (existing) {
return { success: false, message: "用户名已被注册" };
}
const hashedPassword = await hash(password, BCRYPT_ROUNDS);
const [newUser] = await dbGlobal
.insert(usersTable)
.values({
username,
password: hashedPassword,
})
.returning({ id: usersTable.id, username: usersTable.username, role: usersTable.role });
const token = signToken({ userId: newUser.id, username: newUser.username });
return { success: true, token, user: newUser };
}
// ========== Login ==========
export async function loginUser(username: string, password: string, captchaToken: string, captchaCode: string) {
const validCaptcha = await verifyAndConsumeCaptcha(captchaToken, captchaCode);
if (!validCaptcha) {
return { success: false, message: "验证码错误或已过期" };
}
const [user] = await dbGlobal
.select()
.from(usersTable)
.where(eq(usersTable.username, username));
// Constant-time check: always run bcrypt compare even if user doesn't exist
const dummyHash = "$2a$12$" + "0".repeat(53);
const hashToCheck = user ? user.password : dummyHash;
const validPassword = await compare(password, hashToCheck);
if (!user || !validPassword) {
return { success: false, message: "用户名或密码错误" };
}
const token = signToken({ userId: user.id, username: user.username });
return {
success: true,
token,
user: {
id: user.id,
username: user.username,
email: user.email,
nickname: user.nickname,
avatar: user.avatar,
role: user.role,
},
};
}
// ========== Get current user ==========
export async function getCurrentUser(payload: JwtPayload) {
const [user] = await dbGlobal
.select()
.from(usersTable)
.where(eq(usersTable.id, payload.userId));
if (!user) return null;
return {
id: user.id,
username: user.username,
email: user.email,
nickname: user.nickname,
avatar: user.avatar,
role: user.role,
createdAt: user.createdAt,
};
}
// ========== Cleanup expired captchas ==========
export async function cleanupExpiredCaptchas() {
await dbGlobal
.delete(captchaCodes)
.where(lt(captchaCodes.expiresAt, new Date()));
}
export { getUsers } from "./legacy";