Files
minecraft/dashboard/src/server/authflow.ts
T
Aryan SinghandClaude Fable 5 b043f03d28 Whitelist: write allowlist.json+whitelist.json, verify after reload, learn UUIDs from logs
On 26.2 the file whitelist entries load at startup but 'whitelist reload'
and shutdown saves use the renamed allowlist.json, so panel writes never
took effect live and got wiped on stop. Now the panel writes both file
names, verifies the player actually appears in 'whitelist list' after
reload, falls back to the vanilla add command, and captures each player's
real UUID from 'UUID of player …' log lines for future entries. The auth
watcher's self-heal loop reports a loud notification after 3 failed
retries instead of silently spamming reloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 17:16:22 -05:00

261 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { db, getSetting, logActivity } from "./db";
import { bus } from "./bus";
import { notify } from "./notify";
import { env } from "./env";
import { generatePassword } from "./passwords";
import {
isValidUsername,
whitelistAdd,
whitelistRemove,
whitelistList,
whitelistLive,
kickPlayer,
easyauthList,
easyauthRemove,
easyauthSetPassword,
} from "./mc";
/**
* The approval state machine for offline-mode security:
*
* approve → whitelisted + "pending" with a short registration window
* pending + /register in time → "registered" (can play forever)
* pending + window lapses → whitelist removed, "expired"
* revoke → whitelist + EasyAuth account removed
*
* Enforcement runs in a background watcher that reconciles against
* EasyAuth's registered-account list via RCON.
*/
export type ApprovedPlayer = {
id: number;
username: string;
status: "pending" | "registered" | "expired" | "revoked";
approved_at: number | null;
window_expires_at: number | null;
registered_at: number | null;
last_seen_at: number | null;
note: string | null;
};
function windowMs(): number {
const minutes = Number(getSetting("registration_window_minutes", String(env.registrationWindowMinutes)));
return Math.max(0.5, minutes) * 60 * 1000;
}
export function listApproved(): ApprovedPlayer[] {
return db()
.prepare("SELECT * FROM approved_players ORDER BY approved_at DESC")
.all() as unknown as ApprovedPlayer[];
}
export async function approvePlayer(username: string, note?: string): Promise<ApprovedPlayer> {
if (!isValidUsername(username)) throw new Error("Invalid username (3–16 chars, letters/digits/underscore)");
const existing = db()
.prepare("SELECT * FROM approved_players WHERE username = ?")
.get(username) as unknown as ApprovedPlayer | undefined;
if (existing?.status === "registered") throw new Error(`${username} is already registered`);
if (existing?.status === "pending") throw new Error(`${username} already has an open registration window`);
await whitelistAdd(username); // throws if the server is unreachable — approval requires a running server
const now = Date.now();
const expires = now + windowMs();
db()
.prepare(
`INSERT INTO approved_players(username, status, approved_at, window_expires_at, note)
VALUES(?, 'pending', ?, ?, ?)
ON CONFLICT(username) DO UPDATE SET
status = 'pending', approved_at = excluded.approved_at,
window_expires_at = excluded.window_expires_at, registered_at = NULL,
note = COALESCE(excluded.note, approved_players.note)`
)
.run(username, now, expires, note ?? null);
logActivity("approve", username, `Registration window open for ${Math.round(windowMs() / 60000)} min`);
notify("info", `Approved ${username}`, "They can now join and /register — the window is ticking.");
return db().prepare("SELECT * FROM approved_players WHERE username = ?").get(username) as unknown as ApprovedPlayer;
}
export async function revokePlayer(username: string): Promise<void> {
if (!isValidUsername(username)) throw new Error("Invalid username");
try {
await whitelistRemove(username);
} catch {}
try {
await easyauthRemove(username);
} catch {}
try {
await kickPlayer(username, "Your access was revoked");
} catch {}
db()
.prepare("UPDATE approved_players SET status = 'revoked', window_expires_at = NULL WHERE username = ?")
.run(username);
logActivity("revoke", username);
notify("warn", `Revoked ${username}`, "Whitelist entry and account removed.");
}
/** Re-open a registration window for an expired/revoked player. */
export async function reopenWindow(username: string): Promise<void> {
const row = db()
.prepare("SELECT * FROM approved_players WHERE username = ?")
.get(username) as unknown as ApprovedPlayer | undefined;
if (!row) throw new Error("Unknown player");
if (row.status === "registered") throw new Error("Player is already registered");
await whitelistAdd(username);
db()
.prepare("UPDATE approved_players SET status = 'pending', approved_at = ?, window_expires_at = ? WHERE username = ?")
.run(Date.now(), Date.now() + windowMs(), username);
logActivity("approve", username, "Registration window re-opened");
notify("info", `Re-opened window for ${username}`);
}
/** Set a temporary password for a registered player (returned once, never stored). */
export async function resetPassword(username: string): Promise<string> {
const temp = generatePassword(10);
await easyauthSetPassword(username, temp);
try {
await kickPlayer(username, "Your password was reset — log in with the new one");
} catch {}
logActivity("password_reset", username);
notify("info", `Password reset for ${username}`, "Share the temporary password with them privately.", {
discord: false,
});
return temp;
}
/** Delete the EasyAuth account but keep them whitelisted with a fresh window. */
export async function forceReregister(username: string): Promise<void> {
await easyauthRemove(username);
try {
await kickPlayer(username, "Please rejoin and /register a new password");
} catch {}
await whitelistAdd(username);
db()
.prepare("UPDATE approved_players SET status = 'pending', window_expires_at = ?, registered_at = NULL WHERE username = ?")
.run(Date.now() + windowMs(), username);
logActivity("approve", username, "Forced re-registration");
}
/** Import whitelist entries that were added outside the panel. */
export async function importFromWhitelist(): Promise<number> {
const [wl, registered] = await Promise.all([whitelistList(), easyauthList()]);
const registeredSet = new Set(registered.map((n) => n.toLowerCase()));
let added = 0;
const insert = db().prepare(
`INSERT OR IGNORE INTO approved_players(username, status, approved_at, registered_at, note)
VALUES(?, ?, ?, ?, 'imported from whitelist')`
);
for (const name of wl) {
const isReg = registeredSet.has(name.toLowerCase());
const res = insert.run(name, isReg ? "registered" : "expired", Date.now(), isReg ? Date.now() : null);
if (res.changes > 0) added++;
}
return added;
}
// ── background watcher ──────────────────────────────────────────────────
const g = globalThis as unknown as { __craftdeckAuthWatch?: { timer: NodeJS.Timeout } };
/**
* Consecutive whitelist-heal failures per player. After a few strikes the
* admin gets ONE loud notification instead of a silent 5-second retry loop.
*/
const healFailures = new Map<string, { count: number; notified: boolean }>();
function reportHealFailure(username: string, message: string) {
const entry = healFailures.get(username) ?? { count: 0, notified: false };
entry.count++;
if (entry.count >= 3 && !entry.notified) {
entry.notified = true;
notify(
"error",
`Whitelist is not applying for ${username}`,
`The server keeps rejecting the entry after several retries (${message}). ` +
`Check the Console page and docs/troubleshooting.md — the panel will keep retrying meanwhile.`
);
}
healFailures.set(username, entry);
}
async function reconcile() {
const pending = db()
.prepare("SELECT * FROM approved_players WHERE status = 'pending'")
.all() as unknown as ApprovedPlayer[];
if (pending.length === 0) return;
let registered: Set<string> | null = null;
try {
registered = new Set((await easyauthList()).map((n) => n.toLowerCase()));
} catch {
// Server offline: don't expire windows while nobody could register anyway —
// extend windows by the poll interval instead of punishing the player.
db()
.prepare("UPDATE approved_players SET window_expires_at = window_expires_at + 5000 WHERE status = 'pending'")
.run();
return;
}
// Self-heal: make sure the server's LIVE whitelist actually contains every
// pending player. A `whitelist reload` can get lost (stale RCON connection,
// server restart mid-approval) — without this check the player sits at
// "You are not white-listed" while their window burns down.
try {
const live = new Set((await whitelistLive()).map((n) => n.toLowerCase()));
const missing = pending.filter((p) => !live.has(p.username.toLowerCase()));
if (missing.length > 0) {
let healed = true;
for (const p of missing) {
try {
await whitelistAdd(p.username); // rewrites files + reloads + verifies
} catch (err) {
healed = false;
reportHealFailure(p.username, (err as Error).message);
}
}
// They couldn't have joined yet — give the windows back the lost time.
db()
.prepare("UPDATE approved_players SET window_expires_at = window_expires_at + 5000 WHERE status = 'pending'")
.run();
if (healed) logActivity("whitelist_heal", null, missing.map((m) => m.username).join(", "));
return;
}
healFailures.clear(); // whitelist is consistent again
} catch {
// whitelist list unavailable — fall through and try again next tick
}
const now = Date.now();
for (const p of pending) {
if (registered.has(p.username.toLowerCase())) {
db()
.prepare("UPDATE approved_players SET status = 'registered', registered_at = ?, window_expires_at = NULL WHERE id = ?")
.run(now, p.id);
logActivity("register", p.username);
notify("success", `${p.username} registered`, "They can now log in with their password.");
bus().emit("mc-event", { ts: now, type: "register", player: p.username });
} else if (p.window_expires_at !== null && p.window_expires_at < now) {
try {
await whitelistRemove(p.username);
} catch {}
try {
await kickPlayer(p.username, "Registration window expired — ask the admin to approve you again");
} catch {}
db().prepare("UPDATE approved_players SET status = 'expired' WHERE id = ?").run(p.id);
logActivity("expire", p.username, "Registration window expired");
notify("warn", `Window expired for ${p.username}`, "They didn't register in time. Re-approve when they're ready.");
}
}
}
export function startAuthWatcher() {
if (g.__craftdeckAuthWatch) return;
g.__craftdeckAuthWatch = {
timer: setInterval(() => void reconcile().catch(() => {}), 5000),
};
// React immediately to registration hints from the log watcher.
bus().on("mc-event", (e: { type: string }) => {
if (e.type === "register-hint") void reconcile().catch(() => {});
});
}