362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
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) {
|
|
if (!serverSession.authenticated && config.mode !== "server") {
|
|
const localSession = readLocalSession(config);
|
|
if (localSession.authenticated) {
|
|
const restoredServerSession = await restoreServerSessionFromLocal(config);
|
|
if (restoredServerSession.authenticated) return restoredServerSession;
|
|
return localSession;
|
|
}
|
|
}
|
|
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 restoreServerSessionFromLocal(config) {
|
|
const serverResult = await attemptServerLogin(config.username, config.password);
|
|
if (!serverResult.available || !serverResult.authenticated) return { authenticated: false };
|
|
writeLocalSession(config);
|
|
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 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) {
|
|
if (serverResult.authenticated) {
|
|
writeLocalSession(config);
|
|
return {
|
|
authenticated: true,
|
|
mode: "server",
|
|
user: serverResult.user,
|
|
expiresAt: serverResult.expiresAt
|
|
};
|
|
}
|
|
return { 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;
|
|
}
|