Files
pikasTech-HWLAB/web/hwlab-cloud-web/auth.mjs
T
2026-05-23 18:08:51 +00:00

340 lines
9.5 KiB
JavaScript

const DEFAULT_AUTH_USERNAME = "admin";
const DEFAULT_AUTH_PASSWORD = "hwlab2026";
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const AUTH_STORAGE_KEY = "hwlab.cloudWorkbench.auth.v1";
const AUTH_ROUTE = Object.freeze({
session: "/auth/session",
login: "/auth/login",
logout: "/auth/logout"
});
let activeAuth = null;
let expiryTimer = null;
export async function ensureWorkbenchAuth({ loginShell, appShell }) {
if (!loginShell || !appShell) {
throw new Error("missing auth shell");
}
const config = resolveAuthConfig();
hideWorkbench(appShell);
showLogin(loginShell);
const session = await readActiveSession(config);
if (session.authenticated) {
return activateAuthenticatedWorkbench({ loginShell, appShell, session, config });
}
return new Promise((resolve) => {
initLoginForm({
loginShell,
appShell,
config,
onAuthenticated: (authSession) => {
resolve(activateAuthenticatedWorkbench({ loginShell, appShell, session: authSession, config }));
}
});
});
}
export function initWorkbenchLogout(button) {
if (!button) return;
button.addEventListener("click", async () => {
await logoutActiveSession();
window.location.reload();
});
}
function resolveAuthConfig() {
const configured = globalThis.HWLAB_CLOUD_WEB_CONFIG?.auth ?? {};
const sessionTtlMs = clampSessionTtl(configured.sessionTtlMs ?? configured.ttlMs ?? DEFAULT_SESSION_TTL_MS);
const mode = ["server", "local", "auto"].includes(configured.mode) ? configured.mode : "auto";
return {
mode,
username: nonEmptyString(configured.username, DEFAULT_AUTH_USERNAME),
password: nonEmptyString(configured.password, DEFAULT_AUTH_PASSWORD),
sessionTtlMs
};
}
function nonEmptyString(value, fallback) {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function clampSessionTtl(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return DEFAULT_SESSION_TTL_MS;
return Math.min(Math.max(Math.trunc(parsed), 60_000), 7 * 24 * 60 * 60 * 1000);
}
async function readActiveSession(config) {
if (config.mode !== "local") {
const serverSession = await fetchServerSession();
if (serverSession.available) {
return {
authenticated: serverSession.authenticated,
mode: "server",
user: serverSession.user,
expiresAt: serverSession.expiresAt
};
}
if (config.mode === "server") {
return { authenticated: false, mode: "server" };
}
}
return readLocalSession(config);
}
async function fetchServerSession() {
try {
const response = await fetch(AUTH_ROUTE.session, {
method: "GET",
credentials: "same-origin",
headers: { accept: "application/json" }
});
if (response.status === 404 || response.status === 405) {
return { available: false };
}
const payload = await response.json().catch(() => ({}));
return {
available: true,
authenticated: response.ok && payload.authenticated === true,
user: safeUser(payload.user),
expiresAt: stringOrNull(payload.expiresAt)
};
} catch {
return { available: false };
}
}
function readLocalSession(config) {
const now = Date.now();
const payload = readStoredLocalSession();
if (!payload || payload.expiresAt <= now || payload.username !== config.username) {
clearLocalSession();
return { authenticated: false, mode: "local" };
}
return {
authenticated: true,
mode: "local",
user: { username: payload.username },
expiresAt: new Date(payload.expiresAt).toISOString()
};
}
function readStoredLocalSession() {
try {
const raw = window.localStorage?.getItem(AUTH_STORAGE_KEY);
if (!raw) return null;
const payload = JSON.parse(raw);
if (payload?.version !== 1) return null;
const expiresAt = Number(payload.expiresAt);
if (!Number.isFinite(expiresAt)) return null;
return {
username: typeof payload.username === "string" ? payload.username : "",
expiresAt
};
} catch {
return null;
}
}
function writeLocalSession(config) {
const expiresAt = Date.now() + config.sessionTtlMs;
try {
window.localStorage?.setItem(
AUTH_STORAGE_KEY,
JSON.stringify({
version: 1,
username: config.username,
issuedAt: Date.now(),
expiresAt
})
);
} catch {
// A session can still continue for the current page load when storage is unavailable.
}
return {
authenticated: true,
mode: "local",
user: { username: config.username },
expiresAt: new Date(expiresAt).toISOString()
};
}
function clearLocalSession() {
try {
window.localStorage?.removeItem(AUTH_STORAGE_KEY);
} catch {
// Ignore storage availability errors.
}
}
function initLoginForm({ loginShell, appShell, config, onAuthenticated }) {
const form = required(loginShell, "#login-form");
const usernameInput = required(loginShell, "#login-username");
const passwordInput = required(loginShell, "#login-password");
const submit = required(loginShell, "#login-submit");
const error = required(loginShell, "#login-error");
form.addEventListener("submit", async (event) => {
event.preventDefault();
setLoginError(error, "");
submit.disabled = true;
form.setAttribute("aria-busy", "true");
const username = usernameInput.value.trim();
const password = passwordInput.value;
const result = await attemptLogin({ username, password, config });
submit.disabled = false;
form.removeAttribute("aria-busy");
if (!result.authenticated) {
hideWorkbench(appShell);
showLogin(loginShell);
setLoginError(error, "账号或密码不正确,请重新输入。");
passwordInput.select();
passwordInput.focus();
return;
}
onAuthenticated(result);
});
window.requestAnimationFrame(() => usernameInput.focus());
}
async function attemptLogin({ username, password, config }) {
if (config.mode !== "local") {
const serverResult = await attemptServerLogin(username, password);
if (serverResult.available) {
return serverResult.authenticated
? {
authenticated: true,
mode: "server",
user: serverResult.user,
expiresAt: serverResult.expiresAt
}
: { authenticated: false, mode: "server" };
}
if (config.mode === "server") {
return { authenticated: false, mode: "server" };
}
}
if (username === config.username && password === config.password) {
return writeLocalSession(config);
}
return { authenticated: false, mode: "local" };
}
async function attemptServerLogin(username, password) {
try {
const response = await fetch(AUTH_ROUTE.login, {
method: "POST",
credentials: "same-origin",
headers: {
accept: "application/json",
"content-type": "application/json"
},
body: JSON.stringify({ username, password })
});
if (response.status === 404 || response.status === 405) {
return { available: false };
}
const payload = await response.json().catch(() => ({}));
return {
available: true,
authenticated: response.ok && payload.authenticated === true,
user: safeUser(payload.user),
expiresAt: stringOrNull(payload.expiresAt)
};
} catch {
return { available: false };
}
}
function activateAuthenticatedWorkbench({ loginShell, appShell, session, config }) {
activeAuth = { mode: session.mode, config };
hideLogin(loginShell);
showWorkbench(appShell);
scheduleExpiry(session.expiresAt);
return session;
}
function showLogin(loginShell) {
loginShell.hidden = false;
loginShell.removeAttribute("aria-hidden");
document.body.dataset.authState = "login";
}
function hideLogin(loginShell) {
loginShell.hidden = true;
loginShell.setAttribute("aria-hidden", "true");
}
function showWorkbench(appShell) {
appShell.hidden = false;
appShell.removeAttribute("aria-hidden");
document.body.dataset.authState = "authenticated";
}
function hideWorkbench(appShell) {
appShell.hidden = true;
appShell.setAttribute("aria-hidden", "true");
}
function setLoginError(error, message) {
error.textContent = message;
error.hidden = !message;
}
async function logoutActiveSession() {
window.clearTimeout(expiryTimer);
expiryTimer = null;
clearLocalSession();
if (activeAuth?.mode === "server") {
try {
await fetch(AUTH_ROUTE.logout, {
method: "POST",
credentials: "same-origin",
headers: { accept: "application/json" }
});
} catch {
// Reloading after a failed logout still returns to the login check path.
}
}
}
function scheduleExpiry(expiresAt) {
window.clearTimeout(expiryTimer);
expiryTimer = null;
const deadline = Date.parse(expiresAt ?? "");
if (!Number.isFinite(deadline)) return;
const remaining = deadline - Date.now();
if (remaining <= 0) {
void logoutActiveSession().then(() => window.location.reload());
return;
}
expiryTimer = window.setTimeout(() => {
void logoutActiveSession().then(() => window.location.reload());
}, Math.min(remaining, 2_147_483_647));
}
function required(root, selector) {
const element = root.querySelector(selector);
if (!element) throw new Error(`missing auth element ${selector}`);
return element;
}
function safeUser(user) {
const username = typeof user?.username === "string" && user.username.trim() ? user.username.trim() : DEFAULT_AUTH_USERNAME;
return { username };
}
function stringOrNull(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}