feat: add local access control and managed point clouds
This commit is contained in:
153
server/_core/auth.ts
Normal file
153
server/_core/auth.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user