154 lines
5.1 KiB
TypeScript
154 lines
5.1 KiB
TypeScript
import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
|
|
import { parse as parseCookieHeader } from "cookie";
|
|
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "crypto";
|
|
import type { Request } from "express";
|
|
import { SignJWT, jwtVerify } from "jose";
|
|
import { promisify } from "util";
|
|
import type { User } from "../../drizzle/schema";
|
|
import * as db from "../db";
|
|
import { ENV } from "./env";
|
|
|
|
const scrypt = promisify(scryptCallback);
|
|
const PASSWORD_MIN_LENGTH = 8;
|
|
|
|
function sessionSecret() {
|
|
if (!ENV.cookieSecret || ENV.cookieSecret.length < 32) {
|
|
throw new Error("JWT_SECRET 必须配置且至少 32 个字符");
|
|
}
|
|
return new TextEncoder().encode(ENV.cookieSecret);
|
|
}
|
|
|
|
export function validatePassword(password: string) {
|
|
if (password.length < PASSWORD_MIN_LENGTH) {
|
|
throw new Error(`密码至少需要 ${PASSWORD_MIN_LENGTH} 位`);
|
|
}
|
|
}
|
|
|
|
export async function hashPassword(password: string) {
|
|
validatePassword(password);
|
|
const salt = randomBytes(16);
|
|
const derived = await scrypt(password, salt, 64) as Buffer;
|
|
return `scrypt$${salt.toString("base64url")}$${derived.toString("base64url")}`;
|
|
}
|
|
|
|
export async function verifyPassword(password: string, encoded: string | null) {
|
|
if (!encoded) return false;
|
|
const [scheme, saltValue, hashValue] = encoded.split("$");
|
|
if (scheme !== "scrypt" || !saltValue || !hashValue) return false;
|
|
try {
|
|
const expected = Buffer.from(hashValue, "base64url");
|
|
const actual = await scrypt(password, Buffer.from(saltValue, "base64url"), expected.length) as Buffer;
|
|
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function createSessionToken(user: Pick<User, "id" | "sessionVersion">) {
|
|
return new SignJWT({ userId: user.id, sessionVersion: user.sessionVersion })
|
|
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
|
.setIssuedAt()
|
|
.setExpirationTime(Math.floor((Date.now() + SESSION_TTL_MS) / 1000))
|
|
.sign(sessionSecret());
|
|
}
|
|
|
|
function readSessionCookie(req: Request) {
|
|
if (!req.headers.cookie) return undefined;
|
|
return parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
|
|
}
|
|
|
|
export async function authenticateRequest(req: Request): Promise<User | null> {
|
|
const token = readSessionCookie(req);
|
|
if (!token) return null;
|
|
try {
|
|
const { payload } = await jwtVerify(token, sessionSecret(), { algorithms: ["HS256"] });
|
|
const userId = Number(payload.userId);
|
|
const sessionVersion = Number(payload.sessionVersion);
|
|
if (!Number.isInteger(userId) || !Number.isInteger(sessionVersion)) return null;
|
|
const user = await db.getUserById(userId);
|
|
if (!user || user.isActive !== 1 || user.sessionVersion !== sessionVersion) return null;
|
|
return user;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
type Attempt = { failures: number[] };
|
|
const attempts = new Map<string, Attempt>();
|
|
const RATE_WINDOW_MS = 15 * 60 * 1000;
|
|
const RATE_MAX_FAILURES = 5;
|
|
|
|
function normalizedUsername(username: string) {
|
|
return username.trim().toLowerCase();
|
|
}
|
|
|
|
function attemptKeys(ip: string, username: string) {
|
|
return [`ip:${ip}`, `username:${normalizedUsername(username)}`];
|
|
}
|
|
|
|
function recentFailures(key: string, now = Date.now()) {
|
|
const record = attempts.get(key);
|
|
if (!record) return [];
|
|
record.failures = record.failures.filter(time => now - time < RATE_WINDOW_MS);
|
|
if (record.failures.length === 0) attempts.delete(key);
|
|
return record.failures;
|
|
}
|
|
|
|
export function isLoginRateLimited(ip: string, username: string) {
|
|
return attemptKeys(ip, username).some(key => recentFailures(key).length >= RATE_MAX_FAILURES);
|
|
}
|
|
|
|
export function recordLoginFailure(ip: string, username: string) {
|
|
const now = Date.now();
|
|
for (const key of attemptKeys(ip, username)) {
|
|
const failures = recentFailures(key, now);
|
|
failures.push(now);
|
|
attempts.set(key, { failures });
|
|
}
|
|
}
|
|
|
|
export function clearLoginFailures(ip: string, username: string) {
|
|
for (const key of attemptKeys(ip, username)) attempts.delete(key);
|
|
}
|
|
|
|
export function resetLoginRateLimitForTests() {
|
|
attempts.clear();
|
|
}
|
|
|
|
export async function ensureBootstrapAdmin() {
|
|
if (await db.countActiveSuperAdmins() > 0) return;
|
|
const username = ENV.bootstrapAdminUsername.trim();
|
|
const password = ENV.bootstrapAdminPassword;
|
|
if (!username || !password) {
|
|
throw new Error("系统不存在有效超级管理员,请配置 BOOTSTRAP_ADMIN_USERNAME 和 BOOTSTRAP_ADMIN_PASSWORD");
|
|
}
|
|
validatePassword(password);
|
|
const existing = await db.getUserByUsername(username);
|
|
const passwordHash = await hashPassword(password);
|
|
if (existing) {
|
|
await db.updateUser(existing.id, {
|
|
passwordHash,
|
|
role: "super_admin",
|
|
isActive: 1,
|
|
mustChangePassword: 1,
|
|
sessionVersion: existing.sessionVersion + 1,
|
|
});
|
|
} else {
|
|
await db.createUser({
|
|
username,
|
|
passwordHash,
|
|
name: "系统管理员",
|
|
role: "super_admin",
|
|
isActive: 1,
|
|
mustChangePassword: 1,
|
|
sessionVersion: 0,
|
|
});
|
|
}
|
|
console.log(`[Auth] 已创建引导超级管理员账号:${username}(首次登录必须修改密码)`);
|
|
}
|
|
|
|
export function publicUser(user: User) {
|
|
const { passwordHash: _passwordHash, ...safe } = user;
|
|
return safe;
|
|
}
|