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)
This commit is contained in:
@@ -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_TIME_COST` | `3` | Argon2id iteration count |
|
||||||
| `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes |
|
| `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes |
|
||||||
| `OXICLOUD_DISABLE_REGISTRATION` | false | Disable registration of new user accounts |
|
| `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
|
### Rate Limiting & Account Lockout
|
||||||
|
|
||||||
|
|||||||
+23
@@ -597,6 +597,29 @@ OXICLOUD_WOPI_ENABLED=false
|
|||||||
# Example (only addresses on these two domains can be invited):
|
# Example (only addresses on these two domains can be invited):
|
||||||
#OXICLOUD_EXTERNAL_EMAIL_DOMAINS=partner-a.com,partner-b.io
|
#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
|
# 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
|
# the authenticated caller's user_id. Hitting the cap returns 429 with
|
||||||
# Retry-After. Default 50/hour — generous for legitimate admin invites,
|
# Retry-After. Default 50/hour — generous for legitimate admin invites,
|
||||||
|
|||||||
@@ -470,6 +470,33 @@ pub struct AuthConfig {
|
|||||||
pub hash_parallelism: u32,
|
pub hash_parallelism: u32,
|
||||||
/// Rate limiting / account lockout configuration
|
/// Rate limiting / account lockout configuration
|
||||||
pub rate_limit: RateLimitConfig,
|
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 `<anything>@partner-a.com` or
|
||||||
|
/// `<anything>@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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rate limiting and brute-force protection configuration.
|
/// Rate limiting and brute-force protection configuration.
|
||||||
@@ -521,6 +548,7 @@ impl Default for AuthConfig {
|
|||||||
hash_time_cost: 3,
|
hash_time_cost: 3,
|
||||||
hash_parallelism: 2,
|
hash_parallelism: 2,
|
||||||
rate_limit: RateLimitConfig::default(),
|
rate_limit: RateLimitConfig::default(),
|
||||||
|
registration_allowed_email_domains: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1508,6 +1536,20 @@ impl AppConfig {
|
|||||||
config.auth.rate_limit.lockout_duration_secs = val;
|
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
|
// Feature flags
|
||||||
if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH").map(|v| v.parse::<bool>())
|
if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH").map(|v| v.parse::<bool>())
|
||||||
&& let Ok(val) = enable_auth
|
&& let Ok(val) = enable_auth
|
||||||
|
|||||||
@@ -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
|
// Email-only signup requires SMTP. Without it the welcome mail
|
||||||
// can't be dispatched and the user is stranded with no way to log
|
// 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
|
// in. 503 is the right response: instance-wide policy, no per-user
|
||||||
|
|||||||
@@ -350,6 +350,61 @@ HTTP 200
|
|||||||
jsonpath "$.message" contains "request received"
|
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.
|
# Cleanup — admin deletes both test users.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ OXICLOUD_SMTP_FROM='OxiCloud Tests <test@oxicloud.local>'
|
|||||||
OXICLOUD_SMTP_TLS=none
|
OXICLOUD_SMTP_TLS=none
|
||||||
OXICLOUD_ALLOW_EXTERNAL_USERS=true
|
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
|
# PR 12 — magic-link rate-limit caps lowered so external_users.hurl can
|
||||||
# exercise the cap behaviour with a small, deterministic request count.
|
# exercise the cap behaviour with a small, deterministic request count.
|
||||||
# Production defaults are 50 / 5 / 200 respectively (see example.env).
|
# Production defaults are 50 / 5 / 200 respectively (see example.env).
|
||||||
|
|||||||
Reference in New Issue
Block a user