278 lines
8.0 KiB
TypeScript
278 lines
8.0 KiB
TypeScript
const DEFAULT_AUTH_USERNAME = "admin";
|
|
const DEFAULT_AUTH_PASSWORD = "hwlab2026";
|
|
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
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) {
|
|
const serverSession = await fetchServerSession();
|
|
if (serverSession.available) {
|
|
return {
|
|
authenticated: serverSession.authenticated,
|
|
mode: "server",
|
|
user: serverSession.user,
|
|
expiresAt: serverSession.expiresAt
|
|
};
|
|
}
|
|
|
|
if (config.mode !== "auto" || !config.username || !config.password) {
|
|
return { authenticated: false, mode: "server" };
|
|
}
|
|
|
|
const serverResult = await attemptServerLogin(config.username, config.password);
|
|
if (!serverResult.available || !serverResult.authenticated) {
|
|
return { authenticated: false, mode: "server" };
|
|
}
|
|
return {
|
|
authenticated: true,
|
|
mode: "server",
|
|
user: serverResult.user,
|
|
expiresAt: serverResult.expiresAt
|
|
};
|
|
}
|
|
|
|
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 clearLocalSession() {
|
|
// Legacy local-only workbench auth was removed; keep cleanup idempotent for old browsers.
|
|
try { window.localStorage?.removeItem("hwlab.cloudWorkbench.auth.v1"); } catch {}
|
|
}
|
|
|
|
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 }) {
|
|
const serverResult = await attemptServerLogin(username, password);
|
|
if (serverResult.available && serverResult.authenticated) {
|
|
return {
|
|
authenticated: true,
|
|
mode: "server",
|
|
user: serverResult.user,
|
|
expiresAt: serverResult.expiresAt
|
|
};
|
|
}
|
|
return { authenticated: false, mode: "server" };
|
|
}
|
|
|
|
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;
|
|
}
|