feat: add local access control and managed point clouds

This commit is contained in:
2026-07-22 11:05:48 +08:00
parent 683e4d6c07
commit 8555aa2f2e
46 changed files with 1982 additions and 884 deletions

153
server/_core/auth.ts Normal file
View 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;
}