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