add configurable club login domains in RMS software
This commit is contained in:
@@ -4,6 +4,7 @@ const SWR_DETAIL_REFRESH_MS = 20000;
|
|||||||
const DEFAULT_UI_LANGUAGE = "de";
|
const DEFAULT_UI_LANGUAGE = "de";
|
||||||
const SUPPORTED_UI_LANGUAGES = ["de", "en"];
|
const SUPPORTED_UI_LANGUAGES = ["de", "en"];
|
||||||
const LANGUAGE_STORAGE_KEY = "rms-lang";
|
const LANGUAGE_STORAGE_KEY = "rms-lang";
|
||||||
|
const DEFAULT_ALLOWED_LOGIN_DOMAINS = ["arcg.at", "oevsv.at"];
|
||||||
|
|
||||||
let activationWatchTimer = null;
|
let activationWatchTimer = null;
|
||||||
let activationWatchInFlight = false;
|
let activationWatchInFlight = false;
|
||||||
@@ -21,7 +22,8 @@ const state = {
|
|||||||
branding: {
|
branding: {
|
||||||
logoLightUrl: null,
|
logoLightUrl: null,
|
||||||
logoDarkUrl: null
|
logoDarkUrl: null
|
||||||
}
|
},
|
||||||
|
allowedLoginDomains: DEFAULT_ALLOWED_LOGIN_DOMAINS.slice()
|
||||||
},
|
},
|
||||||
accessToken: localStorage.getItem("rms-access-token") || "",
|
accessToken: localStorage.getItem("rms-access-token") || "",
|
||||||
refreshToken: localStorage.getItem("rms-refresh-token") || "",
|
refreshToken: localStorage.getItem("rms-refresh-token") || "",
|
||||||
@@ -251,6 +253,8 @@ const els = {
|
|||||||
maintenanceMessageInput: document.getElementById("maintenanceMessageInput"),
|
maintenanceMessageInput: document.getElementById("maintenanceMessageInput"),
|
||||||
maintenanceEnableBtn: document.getElementById("maintenanceEnableBtn"),
|
maintenanceEnableBtn: document.getElementById("maintenanceEnableBtn"),
|
||||||
maintenanceDisableBtn: document.getElementById("maintenanceDisableBtn"),
|
maintenanceDisableBtn: document.getElementById("maintenanceDisableBtn"),
|
||||||
|
loginDomainsInput: document.getElementById("loginDomainsInput"),
|
||||||
|
saveLoginDomainsBtn: document.getElementById("saveLoginDomainsBtn"),
|
||||||
logoLightFile: document.getElementById("logoLightFile"),
|
logoLightFile: document.getElementById("logoLightFile"),
|
||||||
logoDarkFile: document.getElementById("logoDarkFile"),
|
logoDarkFile: document.getElementById("logoDarkFile"),
|
||||||
uploadLogoLightBtn: document.getElementById("uploadLogoLightBtn"),
|
uploadLogoLightBtn: document.getElementById("uploadLogoLightBtn"),
|
||||||
@@ -817,6 +821,11 @@ function bindEvents() {
|
|||||||
await setMaintenanceMode(false);
|
await setMaintenanceMode(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (els.saveLoginDomainsBtn) {
|
||||||
|
els.saveLoginDomainsBtn.addEventListener("click", async () => {
|
||||||
|
await saveLoginDomains();
|
||||||
|
});
|
||||||
|
}
|
||||||
if (els.uploadLogoLightBtn) {
|
if (els.uploadLogoLightBtn) {
|
||||||
els.uploadLogoLightBtn.addEventListener("click", async () => {
|
els.uploadLogoLightBtn.addEventListener("click", async () => {
|
||||||
await uploadBrandLogo("light", els.logoLightFile);
|
await uploadBrandLogo("light", els.logoLightFile);
|
||||||
@@ -902,6 +911,10 @@ async function refreshFrontendOnResume() {
|
|||||||
async function requestAccess() {
|
async function requestAccess() {
|
||||||
clearMessages("auth");
|
clearMessages("auth");
|
||||||
const email = els.email.value.trim();
|
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;
|
const method = els.authMethodSelect.value;
|
||||||
try {
|
try {
|
||||||
const result = await api("/v1/auth/request-access", {
|
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() {
|
async function handleEmailTokenFromUrl() {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
if (url.searchParams.get("requestApproval") === "1") {
|
if (url.searchParams.get("requestApproval") === "1") {
|
||||||
@@ -1036,14 +1073,16 @@ async function refreshPublicSystemStatus() {
|
|||||||
maintenanceMode: Boolean(result.maintenanceMode),
|
maintenanceMode: Boolean(result.maintenanceMode),
|
||||||
maintenanceMessage: result.maintenanceMessage || "",
|
maintenanceMessage: result.maintenanceMessage || "",
|
||||||
updatedAt: result.updatedAt || null,
|
updatedAt: result.updatedAt || null,
|
||||||
branding: normalizeBranding(result.branding)
|
branding: normalizeBranding(result.branding),
|
||||||
|
allowedLoginDomains: normalizeLoginDomainList(result.allowedLoginDomains)
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
state.system = {
|
state.system = {
|
||||||
maintenanceMode: false,
|
maintenanceMode: false,
|
||||||
maintenanceMessage: "",
|
maintenanceMessage: "",
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
branding: normalizeBranding(null)
|
branding: normalizeBranding(null),
|
||||||
|
allowedLoginDomains: DEFAULT_ALLOWED_LOGIN_DOMAINS.slice()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
renderMaintenanceBanner();
|
renderMaintenanceBanner();
|
||||||
@@ -1165,6 +1204,43 @@ function renderMaintenanceBanner() {
|
|||||||
if (els.maintenanceMessageInput && !els.maintenanceMessageInput.value) {
|
if (els.maintenanceMessageInput && !els.maintenanceMessageInput.value) {
|
||||||
els.maintenanceMessageInput.value = state.system.maintenanceMessage || "";
|
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() {
|
function renderBranding() {
|
||||||
|
|||||||
@@ -407,6 +407,19 @@
|
|||||||
<button id="maintenanceDisableBtn" type="button" class="ghost-btn">Wartungsmodus deaktivieren</button>
|
<button id="maintenanceDisableBtn" type="button" class="ghost-btn">Wartungsmodus deaktivieren</button>
|
||||||
</div>
|
</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>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const config = {
|
|||||||
autoDisableTxBeforeActivation: String(process.env.AUTO_DISABLE_TX_BEFORE_ACTIVATION || "").trim().toLowerCase(),
|
autoDisableTxBeforeActivation: String(process.env.AUTO_DISABLE_TX_BEFORE_ACTIVATION || "").trim().toLowerCase(),
|
||||||
stationMaxUsageSec: Number(process.env.STATION_MAX_USAGE_SEC || 3600),
|
stationMaxUsageSec: Number(process.env.STATION_MAX_USAGE_SEC || 3600),
|
||||||
primaryEmailDomain: String(process.env.PRIMARY_EMAIL_DOMAIN || "arcg.at").toLowerCase(),
|
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 || ""),
|
publicBaseUrl: String(process.env.PUBLIC_BASE_URL || ""),
|
||||||
approverEmails: new Set(
|
approverEmails: new Set(
|
||||||
String(process.env.APPROVER_EMAILS || "")
|
String(process.env.APPROVER_EMAILS || "")
|
||||||
@@ -162,6 +163,7 @@ const runtime = {
|
|||||||
logoLightUrl: null,
|
logoLightUrl: null,
|
||||||
logoDarkUrl: null
|
logoDarkUrl: null
|
||||||
},
|
},
|
||||||
|
loginAllowedDomains: config.loginAllowedDomains,
|
||||||
updatedAt: null
|
updatedAt: null
|
||||||
},
|
},
|
||||||
pluginState: {
|
pluginState: {
|
||||||
@@ -301,6 +303,9 @@ async function routeRequest(req, res) {
|
|||||||
maintenanceMode: Boolean(runtime.systemState.maintenanceMode),
|
maintenanceMode: Boolean(runtime.systemState.maintenanceMode),
|
||||||
maintenanceMessage: runtime.systemState.maintenanceMessage,
|
maintenanceMessage: runtime.systemState.maintenanceMessage,
|
||||||
branding: runtime.systemState.branding,
|
branding: runtime.systemState.branding,
|
||||||
|
allowedLoginDomains: Array.isArray(runtime.systemState.loginAllowedDomains)
|
||||||
|
? runtime.systemState.loginAllowedDomains
|
||||||
|
: config.loginAllowedDomains,
|
||||||
updatedAt: runtime.systemState.updatedAt
|
updatedAt: runtime.systemState.updatedAt
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -849,6 +854,31 @@ async function routeRequest(req, res) {
|
|||||||
return handleMaintenanceUpdate(res, auth.user, body);
|
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") {
|
if (method === "PUT" && url.pathname === "/v1/admin/branding/logo") {
|
||||||
const auth = requireAuth(req, res);
|
const auth = requireAuth(req, res);
|
||||||
if (!auth) return;
|
if (!auth) return;
|
||||||
@@ -940,6 +970,9 @@ async function handleRequestAccess(req, res, body) {
|
|||||||
if (!isValidEmail(email)) {
|
if (!isValidEmail(email)) {
|
||||||
return sendError(res, 400, "auth.invalid_email", "Bitte gueltige E-Mail angeben");
|
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);
|
let user = runtime.users.find((entry) => entry.email === email);
|
||||||
if (runtime.systemState.maintenanceMode) {
|
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) {
|
async function handleAdminBrandingLogoUpdate(res, actor, body) {
|
||||||
const theme = typeof (body && body.theme) === "string" ? body.theme.trim().toLowerCase() : "";
|
const theme = typeof (body && body.theme) === "string" ? body.theme.trim().toLowerCase() : "";
|
||||||
if (theme !== "light" && theme !== "dark") {
|
if (theme !== "light" && theme !== "dark") {
|
||||||
@@ -1743,6 +1792,7 @@ function parseImageDataUrl(value) {
|
|||||||
function normalizeSystemState(state) {
|
function normalizeSystemState(state) {
|
||||||
const input = state && typeof state === "object" ? state : {};
|
const input = state && typeof state === "object" ? state : {};
|
||||||
const branding = input.branding && typeof input.branding === "object" ? input.branding : {};
|
const branding = input.branding && typeof input.branding === "object" ? input.branding : {};
|
||||||
|
const loginAllowedDomains = normalizeLoginDomainList(input.loginAllowedDomains);
|
||||||
return {
|
return {
|
||||||
maintenanceMode: Boolean(input.maintenanceMode),
|
maintenanceMode: Boolean(input.maintenanceMode),
|
||||||
maintenanceMessage: typeof input.maintenanceMessage === "string" && input.maintenanceMessage.trim()
|
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,
|
logoLightUrl: typeof branding.logoLightUrl === "string" && branding.logoLightUrl.trim() ? branding.logoLightUrl : null,
|
||||||
logoDarkUrl: typeof branding.logoDarkUrl === "string" && branding.logoDarkUrl.trim() ? branding.logoDarkUrl : null
|
logoDarkUrl: typeof branding.logoDarkUrl === "string" && branding.logoDarkUrl.trim() ? branding.logoDarkUrl : null
|
||||||
},
|
},
|
||||||
|
loginAllowedDomains: loginAllowedDomains.length > 0 ? loginAllowedDomains : config.loginAllowedDomains,
|
||||||
updatedAt: input.updatedAt || null
|
updatedAt: input.updatedAt || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2495,10 +2546,41 @@ function domainForEmail(email) {
|
|||||||
return at === -1 ? "" : email.slice(at + 1).toLowerCase();
|
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) {
|
function isPrimaryDomainEmail(email) {
|
||||||
return domainForEmail(email) === config.primaryEmailDomain;
|
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() {
|
function stationUsageDurationMs() {
|
||||||
return Math.max(1, Number(config.stationMaxUsageSec || 3600)) * 1000;
|
return Math.max(1, Number(config.stationMaxUsageSec || 3600)) * 1000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,12 +173,25 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
|
|||||||
assert.ok(adminVerify.accessToken, "admin access token expected");
|
assert.ok(adminVerify.accessToken, "admin access token expected");
|
||||||
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
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", {
|
const guestStart = await requestJson(baseUrl, "/v1/auth/request-access", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { email: "guest@example.com" }
|
body: { email: "guest@oevsv.at" }
|
||||||
});
|
});
|
||||||
const outbox2 = await readOutbox(dataDir);
|
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");
|
assert.ok(guestVerifyMail, "guest verify challenge must be written");
|
||||||
|
|
||||||
let guestVerify;
|
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");
|
assert.ok(guestCode, "guest otp code must be parsable");
|
||||||
guestVerify = await requestJson(baseUrl, "/v1/auth/verify-email", {
|
guestVerify = await requestJson(baseUrl, "/v1/auth/verify-email", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { email: "guest@example.com", code: guestCode }
|
body: { email: "guest@oevsv.at", code: guestCode }
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const guestVerifyToken = extractTokenFromText(guestVerifyMail.text);
|
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");
|
assert.equal(guestVerify.approved, false, "external domain should not auto-approve");
|
||||||
|
|
||||||
const approvals = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders });
|
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");
|
assert.ok(pending, "approval request for external user expected");
|
||||||
|
|
||||||
await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, {
|
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 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");
|
assert.ok(guest, "guest user must exist");
|
||||||
|
|
||||||
await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(guest.id)}/auth-methods`, {
|
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", {
|
const otpStart = await requestJson(baseUrl, "/v1/auth/request-access", {
|
||||||
method: "POST",
|
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");
|
assert.equal(otpStart.challengeType, "otp", "OTP challenge should be selected");
|
||||||
|
|
||||||
const outbox3 = await readOutbox(dataDir);
|
const outbox3 = await readOutbox(dataDir);
|
||||||
const guestOtpMail = [...outbox3]
|
const guestOtpMail = [...outbox3]
|
||||||
.reverse()
|
.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");
|
assert.ok(guestOtpMail, "guest OTP mail must be written");
|
||||||
const otpMatch = /Code lautet:\s*(\d{6})/.exec(guestOtpMail.text);
|
const otpMatch = /Code lautet:\s*(\d{6})/.exec(guestOtpMail.text);
|
||||||
assert.ok(otpMatch, "otp code must be parsable");
|
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", {
|
const otpVerify = await requestJson(baseUrl, "/v1/auth/verify-email", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
email: "guest@example.com",
|
email: "guest@oevsv.at",
|
||||||
code: otpMatch[1]
|
code: otpMatch[1]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
assert.ok(otpVerify.accessToken, "guest OTP login should return token");
|
assert.ok(otpVerify.accessToken, "guest OTP login should return token");
|
||||||
|
|
||||||
if (server.exitCode !== null && server.exitCode !== 0) {
|
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) => {
|
test("approver role can process external approvals without admin role", async (t) => {
|
||||||
const rootDir = path.resolve(__dirname, "..");
|
const rootDir = path.resolve(__dirname, "..");
|
||||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-approver-test-"));
|
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: {}
|
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");
|
assert.equal(externalVerify.approved, false, "external user should require approval");
|
||||||
|
|
||||||
const approvalsForApprover = await requestJson(baseUrl, "/v1/approvals", { headers: approverHeaders });
|
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");
|
assert.ok(pending, "approver should see pending external request");
|
||||||
|
|
||||||
await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, {
|
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: {}
|
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");
|
assert.ok(externalLogin.accessToken, "external user should be able to login after approver decision");
|
||||||
|
|
||||||
const operatorVerify = await requestAccessAndVerify(baseUrl, dataDir, "operator@arcg.at");
|
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 adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
||||||
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
||||||
|
|
||||||
const externalEmail = "blocked@external.example";
|
const externalEmail = "blocked@oevsv.at";
|
||||||
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
|
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
|
||||||
assert.equal(externalVerify.approved, false, "external user should require approval");
|
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 adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
||||||
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
||||||
|
|
||||||
const externalEmail = "codes@external.example";
|
const externalEmail = "codes@oevsv.at";
|
||||||
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
|
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
|
||||||
assert.equal(externalVerify.approved, false);
|
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 adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
||||||
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
||||||
|
|
||||||
const externalEmail = "statuscheck@outside.example";
|
const externalEmail = "statuscheck@oevsv.at";
|
||||||
const verify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
|
const verify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
|
||||||
assert.equal(verify.approved, false);
|
assert.equal(verify.approved, false);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user