add configurable club login domains in RMS software

This commit is contained in:
2026-04-02 22:06:19 +02:00
parent 1cc2014034
commit 66b08693b9
4 changed files with 278 additions and 20 deletions

View File

@@ -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() {

View File

@@ -407,6 +407,19 @@
<button id="maintenanceDisableBtn" type="button" class="ghost-btn">Wartungsmodus deaktivieren</button>
</div>
<hr class="separator" />
<div class="section-head">
<h3>Login Domains</h3>
<span class="pill">E-Mail Richtlinie</span>
</div>
<label class="field" style="margin-top: 0.6rem">
<span>Erlaubte Login-Domains (CSV oder Zeilenumbruch)</span>
<textarea id="loginDomainsInput" rows="3" placeholder="arcg.at, oevsv.at"></textarea>
</label>
<div class="actions">
<button id="saveLoginDomainsBtn" type="button" class="ghost-btn">Login Domains speichern</button>
</div>
</article>
</section>

View File

@@ -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;
}

View File

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