diff --git a/public/app.js b/public/app.js index b408fb2..e32e262 100644 --- a/public/app.js +++ b/public/app.js @@ -4,6 +4,7 @@ const SWR_DETAIL_REFRESH_MS = 20000; const DEFAULT_UI_LANGUAGE = "de"; const SUPPORTED_UI_LANGUAGES = ["de", "en"]; const LANGUAGE_STORAGE_KEY = "rms-lang"; +const DEFAULT_ALLOWED_LOGIN_DOMAINS = ["arcg.at", "oevsv.at"]; let activationWatchTimer = null; let activationWatchInFlight = false; @@ -21,7 +22,8 @@ const state = { branding: { logoLightUrl: null, logoDarkUrl: null - } + }, + allowedLoginDomains: DEFAULT_ALLOWED_LOGIN_DOMAINS.slice() }, accessToken: localStorage.getItem("rms-access-token") || "", refreshToken: localStorage.getItem("rms-refresh-token") || "", @@ -251,6 +253,8 @@ const els = { maintenanceMessageInput: document.getElementById("maintenanceMessageInput"), maintenanceEnableBtn: document.getElementById("maintenanceEnableBtn"), maintenanceDisableBtn: document.getElementById("maintenanceDisableBtn"), + loginDomainsInput: document.getElementById("loginDomainsInput"), + saveLoginDomainsBtn: document.getElementById("saveLoginDomainsBtn"), logoLightFile: document.getElementById("logoLightFile"), logoDarkFile: document.getElementById("logoDarkFile"), uploadLogoLightBtn: document.getElementById("uploadLogoLightBtn"), @@ -817,6 +821,11 @@ function bindEvents() { await setMaintenanceMode(false); }); } + if (els.saveLoginDomainsBtn) { + els.saveLoginDomainsBtn.addEventListener("click", async () => { + await saveLoginDomains(); + }); + } if (els.uploadLogoLightBtn) { els.uploadLogoLightBtn.addEventListener("click", async () => { await uploadBrandLogo("light", els.logoLightFile); @@ -902,6 +911,10 @@ async function refreshFrontendOnResume() { async function requestAccess() { clearMessages("auth"); const email = els.email.value.trim(); + if (!isLoginEmailAllowed(email)) { + renderMessage(els.authMessage, `Nur Club-Mailadressen (${formatAllowedDomainsHint()}) sind zum Anmelden moeglich.`, true); + return; + } const method = els.authMethodSelect.value; try { const result = await api("/v1/auth/request-access", { @@ -971,6 +984,30 @@ async function verifyOtpCode() { } } +function isLoginEmailAllowed(email) { + const normalized = String(email || "").trim().toLowerCase(); + if (!normalized || !normalized.includes("@")) { + return false; + } + const atIndex = normalized.lastIndexOf("@"); + if (atIndex < 0) { + return false; + } + const domain = normalized.slice(atIndex + 1); + return getAllowedLoginDomains().includes(domain); +} + +function getAllowedLoginDomains() { + const list = state && state.system && Array.isArray(state.system.allowedLoginDomains) + ? state.system.allowedLoginDomains + : []; + return list.length > 0 ? list : DEFAULT_ALLOWED_LOGIN_DOMAINS.slice(); +} + +function formatAllowedDomainsHint() { + return getAllowedLoginDomains().map((domain) => `@${domain}`).join(" oder "); +} + async function handleEmailTokenFromUrl() { const url = new URL(window.location.href); if (url.searchParams.get("requestApproval") === "1") { @@ -1036,14 +1073,16 @@ async function refreshPublicSystemStatus() { maintenanceMode: Boolean(result.maintenanceMode), maintenanceMessage: result.maintenanceMessage || "", updatedAt: result.updatedAt || null, - branding: normalizeBranding(result.branding) + branding: normalizeBranding(result.branding), + allowedLoginDomains: normalizeLoginDomainList(result.allowedLoginDomains) }; } catch { state.system = { maintenanceMode: false, maintenanceMessage: "", updatedAt: null, - branding: normalizeBranding(null) + branding: normalizeBranding(null), + allowedLoginDomains: DEFAULT_ALLOWED_LOGIN_DOMAINS.slice() }; } renderMaintenanceBanner(); @@ -1165,6 +1204,43 @@ function renderMaintenanceBanner() { if (els.maintenanceMessageInput && !els.maintenanceMessageInput.value) { els.maintenanceMessageInput.value = state.system.maintenanceMessage || ""; } + if (els.loginDomainsInput && document.activeElement !== els.loginDomainsInput) { + els.loginDomainsInput.value = getAllowedLoginDomains().join("\n"); + } +} + +function normalizeLoginDomainList(value) { + const list = Array.isArray(value) + ? value + : String(value || "").split(/[\n,;\s]+/); + const unique = []; + const seen = new Set(); + for (const entry of list) { + const domain = String(entry || "").trim().toLowerCase(); + if (!domain) continue; + if (!/^[a-z0-9.-]+$/.test(domain)) continue; + if (!domain.includes(".")) continue; + if (seen.has(domain)) continue; + seen.add(domain); + unique.push(domain); + } + return unique.length > 0 ? unique : DEFAULT_ALLOWED_LOGIN_DOMAINS.slice(); +} + +async function saveLoginDomains() { + clearMessages("admin"); + const domains = normalizeLoginDomainList(els.loginDomainsInput ? els.loginDomainsInput.value : ""); + try { + const result = await api("/v1/admin/login-domains", { + method: "PUT", + body: { domains } + }); + state.system.allowedLoginDomains = normalizeLoginDomainList(result && result.domains ? result.domains : domains); + renderMaintenanceBanner(); + renderMessage(els.adminMessage, `Login-Domains gespeichert (${formatAllowedDomainsHint()}).`, false, true); + } catch (error) { + renderMessage(els.adminMessage, error.message, true); + } } function renderBranding() { diff --git a/public/index.html b/public/index.html index 7a09316..1eac437 100644 --- a/public/index.html +++ b/public/index.html @@ -407,6 +407,19 @@ +
+
+

Login Domains

+ E-Mail Richtlinie +
+ +
+ +
+ diff --git a/server/index.js b/server/index.js index 6f664b3..ca4e33b 100644 --- a/server/index.js +++ b/server/index.js @@ -71,6 +71,7 @@ const config = { autoDisableTxBeforeActivation: String(process.env.AUTO_DISABLE_TX_BEFORE_ACTIVATION || "").trim().toLowerCase(), stationMaxUsageSec: Number(process.env.STATION_MAX_USAGE_SEC || 3600), primaryEmailDomain: String(process.env.PRIMARY_EMAIL_DOMAIN || "arcg.at").toLowerCase(), + loginAllowedDomains: parseDomainList(process.env.ALLOWED_LOGIN_DOMAINS || "arcg.at,oevsv.at"), publicBaseUrl: String(process.env.PUBLIC_BASE_URL || ""), approverEmails: new Set( String(process.env.APPROVER_EMAILS || "") @@ -162,6 +163,7 @@ const runtime = { logoLightUrl: null, logoDarkUrl: null }, + loginAllowedDomains: config.loginAllowedDomains, updatedAt: null }, pluginState: { @@ -301,6 +303,9 @@ async function routeRequest(req, res) { maintenanceMode: Boolean(runtime.systemState.maintenanceMode), maintenanceMessage: runtime.systemState.maintenanceMessage, branding: runtime.systemState.branding, + allowedLoginDomains: Array.isArray(runtime.systemState.loginAllowedDomains) + ? runtime.systemState.loginAllowedDomains + : config.loginAllowedDomains, updatedAt: runtime.systemState.updatedAt }); } @@ -849,6 +854,31 @@ async function routeRequest(req, res) { return handleMaintenanceUpdate(res, auth.user, body); } + if (method === "GET" && url.pathname === "/v1/admin/login-domains") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + return sendJson(res, 200, { + ok: true, + domains: Array.isArray(runtime.systemState.loginAllowedDomains) + ? runtime.systemState.loginAllowedDomains + : config.loginAllowedDomains, + updatedAt: runtime.systemState.updatedAt + }); + } + + if (method === "PUT" && url.pathname === "/v1/admin/login-domains") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + const body = await readJsonBody(req); + return handleLoginDomainsUpdate(res, auth.user, body); + } + if (method === "PUT" && url.pathname === "/v1/admin/branding/logo") { const auth = requireAuth(req, res); if (!auth) return; @@ -940,6 +970,9 @@ async function handleRequestAccess(req, res, body) { if (!isValidEmail(email)) { return sendError(res, 400, "auth.invalid_email", "Bitte gueltige E-Mail angeben"); } + if (!isLoginDomainAllowed(email)) { + return sendError(res, 403, "auth.email.domain_forbidden", "Nur Club-Mailadressen (@arcg.at oder @oevsv.at) sind zum Anmelden moeglich"); + } let user = runtime.users.find((entry) => entry.email === email); if (runtime.systemState.maintenanceMode) { @@ -1573,6 +1606,22 @@ async function handleMaintenanceUpdate(res, actor, body) { }); } +async function handleLoginDomainsUpdate(res, actor, body) { + const domains = normalizeLoginDomainList(body && body.domains); + if (domains.length === 0) { + return sendError(res, 400, "auth.login_domains.invalid", "Mindestens eine gueltige Domain ist erforderlich"); + } + runtime.systemState.loginAllowedDomains = domains; + runtime.systemState.updatedAt = new Date().toISOString(); + await saveSystemState(); + await appendAudit("admin.login_domains.update", actor, { domains }); + return sendJson(res, 200, { + ok: true, + domains, + updatedAt: runtime.systemState.updatedAt + }); +} + async function handleAdminBrandingLogoUpdate(res, actor, body) { const theme = typeof (body && body.theme) === "string" ? body.theme.trim().toLowerCase() : ""; if (theme !== "light" && theme !== "dark") { @@ -1743,6 +1792,7 @@ function parseImageDataUrl(value) { function normalizeSystemState(state) { const input = state && typeof state === "object" ? state : {}; const branding = input.branding && typeof input.branding === "object" ? input.branding : {}; + const loginAllowedDomains = normalizeLoginDomainList(input.loginAllowedDomains); return { maintenanceMode: Boolean(input.maintenanceMode), maintenanceMessage: typeof input.maintenanceMessage === "string" && input.maintenanceMessage.trim() @@ -1752,6 +1802,7 @@ function normalizeSystemState(state) { logoLightUrl: typeof branding.logoLightUrl === "string" && branding.logoLightUrl.trim() ? branding.logoLightUrl : null, logoDarkUrl: typeof branding.logoDarkUrl === "string" && branding.logoDarkUrl.trim() ? branding.logoDarkUrl : null }, + loginAllowedDomains: loginAllowedDomains.length > 0 ? loginAllowedDomains : config.loginAllowedDomains, updatedAt: input.updatedAt || null }; } @@ -2495,10 +2546,41 @@ function domainForEmail(email) { return at === -1 ? "" : email.slice(at + 1).toLowerCase(); } +function parseDomainList(value) { + return normalizeLoginDomainList(String(value || "")); +} + +function normalizeLoginDomainList(value) { + const list = Array.isArray(value) + ? value + : String(value || "") + .split(/[\n,;\s]+/); + const unique = []; + const seen = new Set(); + for (const entry of list) { + const domain = String(entry || "").trim().toLowerCase(); + if (!domain) continue; + if (!/^[a-z0-9.-]+$/.test(domain)) continue; + if (!domain.includes(".")) continue; + if (seen.has(domain)) continue; + seen.add(domain); + unique.push(domain); + } + return unique; +} + function isPrimaryDomainEmail(email) { return domainForEmail(email) === config.primaryEmailDomain; } +function isLoginDomainAllowed(email) { + const allowed = Array.isArray(runtime.systemState && runtime.systemState.loginAllowedDomains) + ? runtime.systemState.loginAllowedDomains + : config.loginAllowedDomains; + const domain = domainForEmail(email); + return allowed.includes(domain); +} + function stationUsageDurationMs() { return Math.max(1, Number(config.stationMaxUsageSec || 3600)) * 1000; } diff --git a/test/auth-methods.integration.test.js b/test/auth-methods.integration.test.js index 49fd695..3c5d647 100644 --- a/test/auth-methods.integration.test.js +++ b/test/auth-methods.integration.test.js @@ -173,12 +173,25 @@ test("plugin-based auth methods support link and OTP flows", async (t) => { assert.ok(adminVerify.accessToken, "admin access token expected"); const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; + let forbiddenDomainError = null; + try { + await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: "outsider@gmail.com" } + }); + } catch (error) { + forbiddenDomainError = error; + } + assert.ok(forbiddenDomainError, "non-club domains should be rejected immediately"); + assert.equal(forbiddenDomainError.status, 403); + assert.equal(forbiddenDomainError.payload && forbiddenDomainError.payload.error && forbiddenDomainError.payload.error.code, "auth.email.domain_forbidden"); + const guestStart = await requestJson(baseUrl, "/v1/auth/request-access", { method: "POST", - body: { email: "guest@example.com" } + body: { email: "guest@oevsv.at" } }); const outbox2 = await readOutbox(dataDir); - const guestVerifyMail = [...outbox2].reverse().find((entry) => entry.to === "guest@example.com"); + const guestVerifyMail = [...outbox2].reverse().find((entry) => entry.to === "guest@oevsv.at"); assert.ok(guestVerifyMail, "guest verify challenge must be written"); let guestVerify; @@ -187,7 +200,7 @@ test("plugin-based auth methods support link and OTP flows", async (t) => { assert.ok(guestCode, "guest otp code must be parsable"); guestVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { method: "POST", - body: { email: "guest@example.com", code: guestCode } + body: { email: "guest@oevsv.at", code: guestCode } }); } else { const guestVerifyToken = extractTokenFromText(guestVerifyMail.text); @@ -200,7 +213,7 @@ test("plugin-based auth methods support link and OTP flows", async (t) => { assert.equal(guestVerify.approved, false, "external domain should not auto-approve"); const approvals = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); - const pending = (approvals.approvals || []).find((entry) => entry.email === "guest@example.com" && entry.status === "pending"); + const pending = (approvals.approvals || []).find((entry) => entry.email === "guest@oevsv.at" && entry.status === "pending"); assert.ok(pending, "approval request for external user expected"); await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, { @@ -210,7 +223,7 @@ test("plugin-based auth methods support link and OTP flows", async (t) => { }); const users = await requestJson(baseUrl, "/v1/admin/users", { headers: adminHeaders }); - const guest = (users.users || []).find((entry) => entry.email === "guest@example.com"); + const guest = (users.users || []).find((entry) => entry.email === "guest@oevsv.at"); assert.ok(guest, "guest user must exist"); await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(guest.id)}/auth-methods`, { @@ -224,14 +237,14 @@ test("plugin-based auth methods support link and OTP flows", async (t) => { const otpStart = await requestJson(baseUrl, "/v1/auth/request-access", { method: "POST", - body: { email: "guest@example.com", method: "otp-email" } + body: { email: "guest@oevsv.at", method: "otp-email" } }); assert.equal(otpStart.challengeType, "otp", "OTP challenge should be selected"); const outbox3 = await readOutbox(dataDir); const guestOtpMail = [...outbox3] .reverse() - .find((entry) => entry.to === "guest@example.com" && /Code lautet:\s*\d{6}/.test(entry.text)); + .find((entry) => entry.to === "guest@oevsv.at" && /Code lautet:\s*\d{6}/.test(entry.text)); assert.ok(guestOtpMail, "guest OTP mail must be written"); const otpMatch = /Code lautet:\s*(\d{6})/.exec(guestOtpMail.text); assert.ok(otpMatch, "otp code must be parsable"); @@ -239,10 +252,10 @@ test("plugin-based auth methods support link and OTP flows", async (t) => { const otpVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { method: "POST", body: { - email: "guest@example.com", - code: otpMatch[1] - } - }); + email: "guest@oevsv.at", + code: otpMatch[1] + } + }); assert.ok(otpVerify.accessToken, "guest OTP login should return token"); if (server.exitCode !== null && server.exitCode !== 0) { @@ -346,6 +359,80 @@ test("maintenance mode keeps admin active and blocks non-admin login", async (t) } }); +test("admin can configure allowed login domains", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-auth-domains-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + const initial = await requestJson(baseUrl, "/v1/admin/login-domains", { headers: adminHeaders }); + assert.equal(initial.ok, true); + assert.ok(Array.isArray(initial.domains)); + assert.ok(initial.domains.includes("arcg.at")); + assert.ok(initial.domains.includes("oevsv.at")); + + const update = await requestJson(baseUrl, "/v1/admin/login-domains", { + method: "PUT", + headers: adminHeaders, + body: { domains: ["arcg.at"] } + }); + assert.equal(update.ok, true); + assert.deepEqual(update.domains, ["arcg.at"]); + + let forbidden = null; + try { + await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: "guest@oevsv.at" } + }); + } catch (error) { + forbidden = error; + } + assert.ok(forbidden, "oevsv domain should be blocked after policy update"); + assert.equal(forbidden.status, 403); + assert.equal(forbidden.payload && forbidden.payload.error && forbidden.payload.error.code, "auth.email.domain_forbidden"); + + const allowed = await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: "member@arcg.at" } + }); + assert.equal(allowed.ok, true); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + test("approver role can process external approvals without admin role", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-approver-test-")); @@ -417,11 +504,11 @@ test("approver role can process external approvals without admin role", async (t body: {} }); - const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, "remote@outside.example"); + const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, "remote@oevsv.at"); assert.equal(externalVerify.approved, false, "external user should require approval"); const approvalsForApprover = await requestJson(baseUrl, "/v1/approvals", { headers: approverHeaders }); - const pending = (approvalsForApprover.approvals || []).find((entry) => entry.email === "remote@outside.example" && entry.status === "pending"); + const pending = (approvalsForApprover.approvals || []).find((entry) => entry.email === "remote@oevsv.at" && entry.status === "pending"); assert.ok(pending, "approver should see pending external request"); await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, { @@ -430,7 +517,7 @@ test("approver role can process external approvals without admin role", async (t body: {} }); - const externalLogin = await requestAccessAndVerify(baseUrl, dataDir, "remote@outside.example"); + const externalLogin = await requestAccessAndVerify(baseUrl, dataDir, "remote@oevsv.at"); assert.ok(externalLogin.accessToken, "external user should be able to login after approver decision"); const operatorVerify = await requestAccessAndVerify(baseUrl, dataDir, "operator@arcg.at"); @@ -482,7 +569,7 @@ test("rejected external user can request approval again via request-approval flo const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; - const externalEmail = "blocked@external.example"; + const externalEmail = "blocked@oevsv.at"; const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); assert.equal(externalVerify.approved, false, "external user should require approval"); @@ -564,7 +651,7 @@ test("auth error codes remain stable for denied/not-approved/maintenance", async const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; - const externalEmail = "codes@external.example"; + const externalEmail = "codes@oevsv.at"; const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); assert.equal(externalVerify.approved, false); @@ -1013,7 +1100,7 @@ test("approvals list exposes account status and supports repeated decisions", as const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; - const externalEmail = "statuscheck@outside.example"; + const externalEmail = "statuscheck@oevsv.at"; const verify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); assert.equal(verify.approved, false);