From 01da450cf6ba5cffe92822dcccfaa18d2a42b21b Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 13 Jul 2026 23:37:23 +0200 Subject: [PATCH] feat(registration): add a domain allow list add: - OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS to specify list of domains allowing a self registration - OXICLOUD_REQUIRE_VERIFIED_EMAIL=true|false - OXICLOUD_AUTH_METHODS=password,magic_link (login methods, OIDC is on top of this) - OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users (OIDC is on top) --- docs/config/env.md | 1 + example.env | 23 +++++++++ src/common/config.rs | 42 ++++++++++++++++ src/interfaces/api/handlers/auth_handler.rs | 41 +++++++++++++++ tests/api/registration.hurl | 55 +++++++++++++++++++++ tests/common/server.env | 10 ++++ 6 files changed, 172 insertions(+) diff --git a/docs/config/env.md b/docs/config/env.md index e6e3414c..59fadc2e 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -44,6 +44,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_HASH_TIME_COST` | `3` | Argon2id iteration count | | `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes | | `OXICLOUD_DISABLE_REGISTRATION` | false | Disable registration of new user accounts | +| `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` | — | Comma-separated allowlist of email domains accepted on `POST /api/auth/register` (case-insensitive, exact match on the post-`@` part). Empty = any domain is allowed. **Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`**: this one gates SELF-registration (public sign-up), the external list gates INVITATIONS (grants + magic-link to third parties). An operator can lock sign-up to their company domain while leaving invitations open. Subdomains must be listed explicitly. Rejected registrations return 403 `RegistrationDomainNotAllowed` and emit an `audit` line. Example: `mycompany.com,mycompany-eu.com`. | ### Rate Limiting & Account Lockout diff --git a/example.env b/example.env index 5676c70f..361fdfee 100644 --- a/example.env +++ b/example.env @@ -597,6 +597,29 @@ OXICLOUD_WOPI_ENABLED=false # Example (only addresses on these two domains can be invited): #OXICLOUD_EXTERNAL_EMAIL_DOMAINS=partner-a.com,partner-b.io +# Allowlist of email domains accepted on the public POST /api/auth/register +# endpoint. Comma-separated, case-insensitive, exact-match on the post-`@` +# part of the address. Empty (the default) = any domain is allowed. +# +# DISTINCT from OXICLOUD_EXTERNAL_EMAIL_DOMAINS above: this one gates +# SELF-registration (a stranger signing up), while the external list +# gates INVITATIONS (an admin/user sharing to an outside address). +# An operator can, for example, keep public sign-up locked to their +# own company domain while allowing invitations to any customer: +# OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS=mycompany.com +# OXICLOUD_EXTERNAL_EMAIL_DOMAINS= (empty) +# +# Wildcards / subdomain semantics are intentionally NOT supported: +# `mycompany.com` does not match `eng.mycompany.com`. List every subdomain +# explicitly when needed. +# +# Rejected registrations return HTTP 403 with error code +# `RegistrationDomainNotAllowed` and log an `audit` line with +# reason=domain_not_allowed for operator visibility. +# +# Example (only staff at these two domains can self-register): +#OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS=mycompany.com,mycompany-eu.com + # Per-sharer rate limit on email-type grants from POST /api/grants. Keyed on # the authenticated caller's user_id. Hitting the cap returns 429 with # Retry-After. Default 50/hour — generous for legitimate admin invites, diff --git a/src/common/config.rs b/src/common/config.rs index 01162899..7a932f86 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -470,6 +470,33 @@ pub struct AuthConfig { pub hash_parallelism: u32, /// Rate limiting / account lockout configuration pub rate_limit: RateLimitConfig, + /// Allowlist of email domains accepted on the public `POST + /// /api/auth/register` endpoint. Empty = no restriction (any + /// domain is allowed). Entries are lowercased and trimmed at + /// load time; matching is case-insensitive exact-match on the + /// post-`@` part of the address. + /// + /// This is DISTINCT from + /// [`MagicLinkConfig::allowed_email_domains`], which gates who + /// can be INVITED (email-typed grants + magic-link login for + /// existing recipients). This list gates SELF-registration + /// only. An operator can, for example, keep public registration + /// open to `partner-a.com` and `partner-b.io` while allowing + /// invitations to any domain — the two lists are independent. + /// + /// Example: `["partner-a.com", "partner-b.io"]` — only + /// addresses `@partner-a.com` or + /// `@partner-b.io` can self-register; everything else + /// is rejected with 403 `RegistrationDomainNotAllowed`. + /// + /// Wildcards / subdomain semantics are intentionally out of + /// scope (mirroring `MagicLinkConfig::allowed_email_domains`): + /// `partner.com` does NOT match `eng.partner.com`. List every + /// subdomain explicitly. + /// + /// Env: `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` (comma- + /// separated). + pub registration_allowed_email_domains: Vec, } /// Rate limiting and brute-force protection configuration. @@ -521,6 +548,7 @@ impl Default for AuthConfig { hash_time_cost: 3, hash_parallelism: 2, rate_limit: RateLimitConfig::default(), + registration_allowed_email_domains: Vec::new(), } } } @@ -1508,6 +1536,20 @@ impl AppConfig { config.auth.rate_limit.lockout_duration_secs = val; } + // Registration email-domain allowlist. Distinct from + // `OXICLOUD_EXTERNAL_EMAIL_DOMAINS` (which gates who can be + // INVITED via grants + magic link) — this one gates who can + // SELF-register via `POST /api/auth/register`. Empty = no + // restriction. Same parse shape as the external-domains list: + // comma-separated, lowercased, trimmed, empties dropped. + if let Ok(v) = env::var("OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS") { + config.auth.registration_allowed_email_domains = v + .split(',') + .map(|d| d.trim().to_ascii_lowercase()) + .filter(|d| !d.is_empty()) + .collect(); + } + // Feature flags if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH").map(|v| v.parse::()) && let Ok(val) = enable_auth diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 6ef8a8e4..ca9228d9 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -153,6 +153,47 @@ pub async fn register( )); } + // Operator-configured allowlist of email domains that can + // self-register. Empty list = no restriction (any domain accepted). + // Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`, which gates + // magic-link / grant invitations — an operator can leave that + // permissive while locking self-registration down, or vice versa. + // + // Matching mirrors the magic-link list: + // * post-`@` part of the address is extracted and lowercased + // * case-insensitive exact match against the allowlist + // * no wildcard / subdomain expansion (list every domain + // explicitly, per the config docstring) + // + // Audit-log denials at the `audit` target so operators can spot + // enumeration / probe attempts — mirrors the shape used by the + // magic-link domain rejection at + // `magic_link_invite_service.rs`. + let allow_list = &state.core.config.auth.registration_allowed_email_domains; + if !allow_list.is_empty() { + let domain = dto + .email + .split('@') + .nth(1) + .map(|d| d.trim().to_ascii_lowercase()) + .unwrap_or_default(); + if domain.is_empty() || !allow_list.iter().any(|d| d == &domain) { + tracing::info!( + target: "audit", + event = "auth.register_rejected", + reason = "domain_not_allowed", + domain = %domain, + "👮🏻‍♂️ Public registration refused: email domain not in \ + OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS" + ); + return Err(AppError::new( + StatusCode::FORBIDDEN, + "Registration is not open to this email domain.", + "RegistrationDomainNotAllowed", + )); + } + } + // Email-only signup requires SMTP. Without it the welcome mail // can't be dispatched and the user is stranded with no way to log // in. 503 is the right response: instance-wide policy, no per-user diff --git a/tests/api/registration.hurl b/tests/api/registration.hurl index 8bd3aa67..4d3c2be7 100644 --- a/tests/api/registration.hurl +++ b/tests/api/registration.hurl @@ -350,6 +350,61 @@ HTTP 200 jsonpath "$.message" contains "request received" +# ───────────────────────────────────────────────────────────── +# Step 12 — OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS gate. +# +# `tests/common/server.env` pins the allowlist to +# `example.com,example.test`. Every legitimate signup above stayed +# inside that set. Now attempt an off-domain address and assert: +# +# * HTTP 403 (NOT the anti-enumeration 200 — instance-wide policy +# is not a per-user oracle; a rejected domain hasn't +# established whether a specific address exists). +# * `RegistrationDomainNotAllowed` error code so operators and +# frontends can distinguish this from other 403 shapes +# (`RegistrationDisabled`, `PasswordRegistrationDisabled`). +# +# The gate is CASE-INSENSITIVE on the post-`@` part — extra +# request with mixed case pins that behaviour so a future refactor +# can't silently regress a lowercase-only match. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/register +Content-Type: application/json +{ + "username": "off-domain", + "email": "someone@nowhere.invalid", + "password": "TestPassword1!" +} + +HTTP 403 +[Asserts] +# `$.error` carries the human-readable message; the stable +# machine-readable code lives at `$.error_type` (see +# `interfaces/errors.rs::ErrorResponse`). Pin `error_type` so a +# future copy-edit of the message doesn't break the test. +jsonpath "$.error_type" == "RegistrationDomainNotAllowed" + + +# Case-insensitive matching regression pin: `EXAMPLE.COM` in the +# post-`@` part is normalised to `example.com` and accepted. Reuse +# charlie's already-taken email so the request lands on the +# anti-enum-200 collision path — this way we exercise the domain +# gate (must pass) without creating a new user that would need +# cleanup, and pin the "case-insensitive normalization" invariant +# in one step. +POST {{base_url}}/api/auth/register +Content-Type: application/json +{ + "username": "case-check", + "email": "charlie@EXAMPLE.COM", + "password": "TestPassword1!" +} + +HTTP 200 +[Asserts] +jsonpath "$.message" contains "request received" + + # ───────────────────────────────────────────────────────────── # Cleanup — admin deletes both test users. # ───────────────────────────────────────────────────────────── diff --git a/tests/common/server.env b/tests/common/server.env index 6c27ee39..2d7b5eff 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -69,6 +69,16 @@ OXICLOUD_SMTP_FROM='OxiCloud Tests ' OXICLOUD_SMTP_TLS=none OXICLOUD_ALLOW_EXTERNAL_USERS=true +# Public-registration email-domain allowlist. Exercised by +# `registration.hurl` step "off-domain rejection" (attempts to +# register with @nowhere.invalid and asserts 403 +# `RegistrationDomainNotAllowed`). Contains BOTH `example.com` (Hurl +# fixtures use it — charlie@example.com etc.) AND `example.test` (E2E +# login.spec uses it — reg-*@example.test). Every legitimate test +# path stays inside the allowlist; the rejection test picks a domain +# outside it deliberately. +OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS=example.com,example.test + # PR 12 — magic-link rate-limit caps lowered so external_users.hurl can # exercise the cap behaviour with a small, deterministic request count. # Production defaults are 50 / 5 / 200 respectively (see example.env).