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);