diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 8329bf6d..8a24fcdd 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -116,6 +116,7 @@ export default defineConfig({ { text: "File and Blob lifecycle", link: "/architecture/file-and-blob-lifecycle" }, { text: "ReBAC & Authorization", link: "/architecture/rebac-authorization" }, { text: "User lifecycle", link: "/architecture/user-lifecycle" }, + { text: "Magic-link auth", link: "/architecture/magic-link-auth" }, ], }, { text: "FAQ", link: "/faq" }, diff --git a/docs/architecture/magic-link-auth.md b/docs/architecture/magic-link-auth.md new file mode 100644 index 00000000..f4151eec --- /dev/null +++ b/docs/architecture/magic-link-auth.md @@ -0,0 +1,200 @@ +# Magic-Link External Authentication + +OxiCloud supports sharing resources with people who do not yet have an account on the instance, via per-email invitations and per-email sign-in links. Recipients are provisioned lazily as **external users** and authenticate exclusively through one-time URLs delivered by email, until they later set a password or link an OIDC identity. + +This page is the architectural overview. For configuration knobs, see [Environment Variables](/config/env). For how grants are evaluated, see [ReBAC Authorization](/architecture/rebac-authorization). For how shares relate to grants, see [Share Integration](/architecture/share-integration). + +## Why this exists + +Two scenarios are not covered by username/password or OIDC: + +1. **Sharing with someone who is not yet an OxiCloud user.** The sharer should be able to type an email address into the share modal and let the server handle the rest. +2. **A pre-existing external user has lost their bookmark.** They never had a password — the only way to get back in is a fresh magic link to the address on file. + +Both are handled by the same magic-link primitive: a single-use, time-limited token issued out of band (by email) that exchanges for a session on redemption. + +## Two flows + +``` +Invitation flow Login-via-email flow +─────────────── ──────────────────── +Alice fills share modal Bob hits /login, types his email + │ │ +POST /api/grants POST /api/auth/magic-link/send +{ subject.type: "email" } { email: "bob@example.com" } + │ │ +resolve_or_create_recipient find user by email + ├─ found → reuse (no creation here) + └─ new → User::new_external │ + │ │ +mint token (resource_type/id set) mint token (NULL resource) + │ │ +queue invitation email queue sign-in email + │ │ +return GrantDto (201) return uniform 200 + │ │ + └──────────────┬──────────────────────────┘ + │ + recipient clicks /magic/v1/{token} + │ + validate, mark used, issue cookies + │ + ┌──────────────┴──────────────────────────┐ + ↓ ↓ +Redirect to /#/files/folder/{id} Redirect to /#/sharedwithme +(resource target) (no resource target) +``` + +The same redemption endpoint serves both — the only difference is the landing redirect, which is decided by whether `magic_link_tokens.resource_id IS NULL`. + +## Identity model for external users + +A user is **magic-link-eligible** if and only if they have no other authentication method configured. The single source of truth is `User::has_login_credential()`: + +| State | `password_hash` | `oidc_subject` | Eligible? | +|--------------------------------|------------------------------|----------------|-----------| +| External, freshly invited | `__EXTERNAL_NO_PASSWORD__` | NULL | yes | +| External who set a password | real Argon2 hash | NULL | no | +| External who linked OIDC | `__OIDC_NO_PASSWORD__` | set | no | +| Internal, password | real Argon2 hash | NULL | no | +| Internal, OIDC-only | `__OIDC_NO_PASSWORD__` | set | no | + +The placeholder strings (`__EXTERNAL_NO_PASSWORD__`, `__OIDC_NO_PASSWORD__`) are an acknowledged smell. A future refactor introduces an `auth.user_auth_methods` side-table with one row per `(user_id, method_type)`; the migration touches the body of `has_login_credential()` only. + +The eligibility rule rules out one specific bypass: an internal user with a password cannot be signed in via a magic link sent to their mailbox. Mailbox ownership is not a substitute for the password — that distinction matters when mailboxes are easier to compromise than passwords (mail-forwarding rules, shared aliases, etc.). + +### Username and display + +- External users get `username = normalised_email`. Login forms accept username OR email; lookup tries `username` first, falls back to `email`. +- The `auth.users.username` column was widened from 32 to 254 chars (RFC 5321 maximum) when this work landed. +- `auth.users.given_name` and `auth.users.family_name` are `TEXT NULL` — populated from OIDC claims at JIT provisioning; external users get NULL initially and can fill them in later. +- Home folder name (`"My Folder - alice"`) is **not** renamed when username changes — it was display text at creation; the folder is semantically owned by `user_id`. + +### Email normalisation + +Every email crossing the boundary into the DB or a rate-limit key goes through `domain::services::email_normalize::normalize_email`: + +1. Trim whitespace. +2. Split on the **last** `@`. +3. Lowercase the local part. +4. Punycode-encode the domain via `idna::domain_to_ascii`. + +So `Alice@Example.COM`, ` alice@example.com `, and `alice@münchen.de` all map to a stable ASCII form before storage or comparison. Gmail's `+tag` and `.` insensitivities are deliberately **not** special-cased — addresses are treated as opaque strings post-normalisation. + +## Token lifecycle + +``` + ┌─────────┐ + (insert) ─────► │ pending │ ─── redeem ──► ┌──────┐ + │ │ │ used │ + └────┬────┘ └──────┘ + │ + (sweeper, TTL) + ↓ + ┌─────────┐ + │ expired │ + └─────────┘ +``` + +`auth.magic_link_tokens` mirrors `auth.device_codes` exactly: PostgreSQL ENUM status, 32-byte CSPRNG token in base64url, single-use via `UPDATE … WHERE status = 'pending'`, partial index on `expires_at WHERE pending`, and a background sweeper that promotes pending-and-overdue rows to expired. + +Salient properties: + +- **Single-use** — second redemption attempt rejected as "link already used". +- **TTL-enforced** — `expires_at < NOW()` → "link expired". TTL is `OXICLOUD_MAGIC_LINK_TTL_HOURS`, default 24. +- **Token in path, not query** — `GET /magic/v1/{token}` so the secret stays out of `Referer` headers. +- **302 immediately on success** — the URL is replaced in the address bar before the user can navigate away or screenshot it. +- **Optional resource target** — `resource_type` + `resource_id` columns, with a `CHECK ((resource_type IS NULL) = (resource_id IS NULL))` constraint to make the two-or-neither rule explicit. + +## User-profile visibility rule (`GET /api/users/{id}`) + +The endpoint is the cornerstone of the share modal's "who is this person" rendering. Its visibility rule is intentionally narrow, evaluated in this order: + +1. **Self** — caller asks for their own profile. +2. **Shared-grant relationship** — caller and target share at least one access grant in either direction. Applies to internal AND external callers; this is what lets a recipient resolve the granter's name/photo in the SharedWithMe view. +3. **External lockout** — if the caller is external and rule 2 did not match, stop and return 404. +4. **Directory exposure** — if the target is internal AND `OXICLOUD_EXPOSE_SYSTEM_USERS=true`, return the target. +5. **Admin** — admins can always look up any user. +6. **404** — otherwise. Same response as "user does not exist" (anti-enumeration). + +A per-caller sliding-window rate limit (60 req/minute) guards against an attacker iterating UUIDs against rule 2 with a stale JWT. The visibility rule alone is sufficient defence-in-principle; the rate limit makes the attack uneconomical. + +## Audit events + +Every denial or rejection in the magic-link path emits a structured event on the `audit` tracing target. Operators tail `target=audit` for compliance and incident response. + +| Event | Reasons (subset) | Where it fires | +|------------------------------------|---------------------------------------------------------------------------------------------------|----------------------------------------------------------| +| `authz.denied` | permission missing | `AuthorizationEngine::require` | +| `auth.login` | `user_not_found`, `bad_password`, `account_deactivated` | `AuthApplicationService::login` | +| `auth.magic_link_send` | `sent`, `no_account`, `has_credential`, `account_deactivated`, `malformed_email`, `rate_limited_ip`, `rate_limited_email` | `MagicLinkInviteService::send_login_link` and the handler | +| `auth.magic_link_redeem` | `redeemed`, `token_not_found`, `token_used`, `token_expired`, `account_deactivated` | `MagicLinkInviteService::redeem` | +| `user_profile.rejected` | `external_no_relationship`, `target_external_hidden`, `target_hidden` | `AuthApplicationService::get_user_profile` | +| `grants.email_invite` | `rate_limited` | `grant_handler::create_grant` | + +The convention (see CLAUDE.md § Authorization) is: any branch that denies or rejects a request **must** emit an audit event before returning the user-facing response. Anti-enumeration is preserved at the API surface (uniform response shape, 404 not 403), and the true reason is recorded only in the audit channel. + +## Rate limits + +Three caps protect the magic-link surface. Each is a moka sliding-window counter; the keys differ. + +| Cap | Keyed on | Default | Env var | Visible on hit? | +|----------------------------------------------|--------------------------------|-----------|------------------------------------------------------|------------------| +| Per-sharer email-invite | `caller_id` | 50 / hour | `OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR` | yes (429 + `Retry-After`) | +| Per-target-email send | normalised email | 5 / hour | `OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR` | no (uniform 200) | +| Per-source-IP send (backstop) | trusted client IP | 200 / hour | `OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR` | no (uniform 200) | + +Two distinct visibility regimes: + +- **Authenticated callers** see 429 when they hit a cap, because their own rate-limit state leaks nothing about other accounts. The invite cap is in this regime. +- **Anonymous callers** never see 429 on the send endpoint — the status itself would become an enumeration oracle ("this email has been probed recently → probably has an account"). The two send caps are silently absorbed: the response stays the uniform 200, and the real reason is recorded in the audit channel. + +An authenticated caller resending to themselves bypasses both send caps. The bypass signal is "presence (not validity) of an Authorization header or access cookie" — a stale-cookie holder gets a 401 from any other endpoint they touch, so the worst-case bypass is narrow. + +The per-IP backstop respects `OXICLOUD_TRUST_PROXY_CIDR` for client IP resolution: behind a reverse proxy, the upstream IP from `X-Forwarded-For` (leftmost) is used; without a configured trusted CIDR, the proxy's IP would be used and the backstop would effectively become a single bucket. + +## Defence-in-depth boundary protections + +External users are a new principal kind, and several pre-existing surfaces would over-share once they appeared. Three protections close those gaps: + +1. **Subject groups reject external members.** `subject_group_service.rs::add_member` short-circuits if the candidate user has `is_external = TRUE`. Otherwise an admin could add `alice@example.com` to "Engineering", which later receives a grant on internal-only resources — silent privilege escalation. Mirrors the no-external-admins enforcement. +2. **System contacts hide externals by default.** `auth_service.list_users` and `auth_service.search_users` take `include_external: bool`, defaulting to `false`. The share modal autocomplete (via `/api/address-books/system/contacts`) therefore never surfaces external users to internal callers, and external users never see internal users at the address book layer. +3. **External users are excluded from the Internal virtual group.** `pg_acl_engine.rs::expand_user` no longer inserts `INTERNAL_GROUP_ID` for users with `is_external = TRUE`. The group's name finally honours its semantics; every grant addressed to "all internal users" is now genuinely internal-only. + +These three protections all activate at the **service layer**, so every protocol surface (REST, WebDAV, CalDAV, NextCloud) inherits them automatically. + +Pre-existing safeguards from the user-lifecycle work continue to apply: the DB CHECK constraints `users_external_not_admin` and `users_external_no_storage`, and the `HomeFolderLifecycleHook` short-circuit that skips home-folder provisioning for externals. + +## Kill switches and feature scoping + +| Knob | What it does | +|----------------------------------------|--------------------------------------------------------------------------------------------------------| +| `OXICLOUD_ALLOW_EXTERNAL_USERS=false` | Coarse off-switch. `POST /api/grants` rejects email-typed subjects for unknown emails; send endpoint returns the uniform stub without issuing a token. Pre-existing externals continue to function. | +| `OXICLOUD_EXTERNAL_EMAIL_DOMAINS=…` | Fine-grained allowlist of accepted domains for new external users. Empty = no restriction. Exact-match (case-insensitive) on the post-`@` part — `partner.com` does NOT match `eng.partner.com`. | +| `OXICLOUD_SMTP_*` unconfigured | The whole magic-link feature is unavailable. Endpoints that depend on it return `503 Service Unavailable` with a clear message. | +| `OXICLOUD_MAGIC_LINK_TTL_HOURS` | Token lifetime. Default 24 hours. Shortening it raises the resend rate; lengthening it raises the window for token theft. | + +The send endpoint **does** return 503 (not the uniform 200) when SMTP is entirely unconfigured: the absence of the feature is visible from any other `/api/auth/magic-link/*` route anyway, so hiding the 503 leaks nothing the attacker could not learn elsewhere. + +## What is deliberately out of scope + +These are intentionally deferred. Each has a clear future trigger; none block the present design. + +- **`auth.user_auth_methods` side-table.** Replaces the placeholder-string smell. `has_login_credential()` is the single migration point. +- **Email-locale routing.** v1 ships English-only invitation templates. A future PR adds recipient-locale detection (Accept-Language at send time, or stored preference) and a template engine. +- **MX-record validation at share time.** Regex is the only pre-send check; bad domains surface via SMTP bounce. +- **Dormant external user sweeper.** Purges users with no `last_login_at` for 13+ months. The GDPR-purge variant in `UserLifecycleHook::on_user_deleted` is its hook entry point. +- **`OXICLOUD_EXTERNAL_USERS_CAN_RESHARE=false`.** Forbids externals from being a grant's `granted_by`. Today an external with `Permission::Share` can mint more externals — a soft policy worth tightening but not load-bearing. +- **Differentiated session TTL for externals.** Uniform across all users today. Future env: `OXICLOUD_EXTERNAL_REFRESH_TOKEN_EXPIRY_DAYS`. +- **`session_kind` on sessions emitted from magic-link.** Enables scoped sessions ("magic-link session can only access granted resources, not the user's own folders"). External users have no own folders so the practical exposure is small. +- **Admin-list-users that includes externals.** Today `list_users` filters externals by default. A future admin UI for managing externals (rename, deactivate, view their grants) will need an `include_external` query param. +- **Open Cloud Mesh (OCM) federation.** A separate path for external identity; `ExternalIdentityLifecycleHook::on_user_created` accommodates a `source` discriminator (`magic_link` / `oidc` / `ocm`). +- **WebAuthn / passkey enrolment.** Distinct future feature; magic-link is the bootstrap. +- **Bounce tracking.** No webhook listener for SES-style bounce notifications. A future `on_email_bounce` event would surface "this user's email is dead" in admin UI. + +## Related documents + +- [User lifecycle](/architecture/user-lifecycle) — the hook framework that fires on user creation and the deletion modes. +- [ReBAC Authorization](/architecture/rebac-authorization) — how grants are evaluated against `auth.users` rows (including external ones). +- [Share Integration](/architecture/share-integration) — how the public-share-link flow relates to the email-invite flow (both create `access_grants` rows; only the former lives in `storage.shares`). +- [Environment Variables](/config/env) — the full set of `OXICLOUD_*` knobs. diff --git a/docs/architecture/share-integration.md b/docs/architecture/share-integration.md index 8c2025c7..e998dba5 100644 --- a/docs/architecture/share-integration.md +++ b/docs/architecture/share-integration.md @@ -4,6 +4,8 @@ OxiCloud supports public file and folder sharing through signed share links. A s > **Where permission and expiration live now.** Both the granted permissions and the expiration timestamp are stored on the `storage.access_grants` row that represents the share, not on the share row itself. They are evaluated by the same `AuthorizationEngine` that handles user and group grants — see [ReBAC Authorization](/architecture/rebac-authorization). The `storage.shares` row keeps only the token-side metadata (public token, password hash, item name, access count). +> **Sharing with people who do not yet have an account.** Token-based shares are anonymous; anyone with the URL can use them. To share with a specific person who isn't on the instance yet, the share modal accepts a raw email address and provisions the recipient as an *external user* on the fly. That flow is described in [Magic-link external authentication](/architecture/magic-link-auth), and the resulting grant is a regular per-user `access_grants` row — identical in evaluation to a grant on an internal recipient. + ## What a Share Contains A share record (`storage.shares`) tracks: diff --git a/example.env b/example.env index 1344e3b0..5130c529 100644 --- a/example.env +++ b/example.env @@ -382,6 +382,26 @@ OXICLOUD_WOPI_ENABLED=false # Example (only addresses on these two domains can be invited): #OXICLOUD_EXTERNAL_EMAIL_DOMAINS=partner-a.com,partner-b.io +# 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, +# protective against a compromised account spamming external users. +#OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=50 + +# Per-target-email rate limit on POST /api/auth/magic-link/send. Keyed on +# the normalised recipient address (lowercased local, punycode domain). +# Exceeding the cap is silently absorbed (uniform 200 anti-enumeration); +# audit log records the real reason. Authenticated callers bypass this +# limit (a logged-in user resending to themselves should not be throttled). +# Default 5/hour. +#OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=5 + +# Per-source-IP backstop on POST /api/auth/magic-link/send. Bounds the cost +# of one attacker spreading 5/hr requests across many target addresses. +# Same silently-absorbed behaviour on cap. Honours OXICLOUD_TRUST_PROXY_CIDR +# for client IP resolution. Default 200/hour. +#OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=200 + # ----------------------------------------------------------------------------- # PROXY # ----------------------------------------------------------------------------- diff --git a/src/common/config.rs b/src/common/config.rs index 738acbbf..286d9ebc 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -714,6 +714,21 @@ pub struct MagicLinkConfig { /// `partner.com` does NOT match `eng.partner.com`. List every /// subdomain explicitly. pub allowed_email_domains: Vec, + /// Per-sharer ceiling on email-typed grant invitations from + /// `POST /api/grants`. Keyed on `caller_id`. Exceeding the ceiling + /// returns 429. Default: 50/hour. + pub invite_per_caller_per_hour: u32, + /// Per-target-email ceiling on `POST /api/auth/magic-link/send`, + /// keyed on the normalised recipient address. Anti-bombing. + /// Exceeding the ceiling is silently absorbed (uniform 200) so + /// the response shape can't be used as an enumeration oracle. + /// Default: 5/hour. + pub send_per_email_per_hour: u32, + /// Per-source-IP backstop on `POST /api/auth/magic-link/send`, + /// keyed on the trusted client IP. Bounds the cost of an attacker + /// spreading low per-email volume across many target addresses. + /// Default: 200/hour. + pub send_per_ip_per_hour: u32, } impl Default for MagicLinkConfig { @@ -722,6 +737,9 @@ impl Default for MagicLinkConfig { ttl_hours: 24, allow_external_users: true, allowed_email_domains: Vec::new(), + invite_per_caller_per_hour: 50, + send_per_email_per_hour: 5, + send_per_ip_per_hour: 200, } } } @@ -1350,6 +1368,24 @@ impl AppConfig { .filter(|d| !d.is_empty()) .collect(); } + if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR") + && let Ok(n) = v.parse::() + && n > 0 + { + config.magic_link.invite_per_caller_per_hour = n; + } + if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR") + && let Ok(n) = v.parse::() + && n > 0 + { + config.magic_link.send_per_email_per_hour = n; + } + if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR") + && let Ok(n) = v.parse::() + && n > 0 + { + config.magic_link.send_per_ip_per_hour = n; + } config } @@ -1410,9 +1446,8 @@ mod tests { #[test] fn allowlist_matches_case_insensitively() { let cfg = MagicLinkConfig { - ttl_hours: 24, - allow_external_users: true, allowed_email_domains: vec!["partner-a.com".to_string(), "partner-b.io".to_string()], + ..MagicLinkConfig::default() }; assert!(cfg.is_email_allowed("alice@partner-a.com")); // Uppercase domain in the email — must still match. @@ -1425,9 +1460,8 @@ mod tests { #[test] fn allowlist_does_not_match_subdomains_implicitly() { let cfg = MagicLinkConfig { - ttl_hours: 24, - allow_external_users: true, allowed_email_domains: vec!["partner.com".to_string()], + ..MagicLinkConfig::default() }; assert!(cfg.is_email_allowed("alice@partner.com")); // Subdomain must be listed explicitly — exact match only. @@ -1439,9 +1473,8 @@ mod tests { #[test] fn malformed_email_fails_closed() { let cfg = MagicLinkConfig { - ttl_hours: 24, - allow_external_users: true, allowed_email_domains: vec!["partner.com".to_string()], + ..MagicLinkConfig::default() }; // No `@` — rejected even though allowlist is set. assert!(!cfg.is_email_allowed("not-an-email")); diff --git a/src/common/di.rs b/src/common/di.rs index eb7a0e92..db01a08a 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -932,6 +932,38 @@ impl AppServiceFactory { user_profile_rate_limiter: Arc::new( crate::interfaces::middleware::rate_limit::RateLimiter::new(60, 60, 50_000), ), + // PR 12 — per-sharer email-invite ceiling: caller_id-keyed. + // Defends against a compromised account spamming external + // invites (each invite mints a new external user + email). + // Limits come from MagicLinkConfig so tests / operators can + // tune them via OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR. + email_invite_rate_limiter: Arc::new( + crate::interfaces::middleware::rate_limit::RateLimiter::new( + self.config.magic_link.invite_per_caller_per_hour, + 3_600, + 50_000, + ), + ), + // PR 12 — per-target-email send ceiling on + // /api/auth/magic-link/send. Stops the endpoint from being + // an email-bombing primitive against a known address. + magic_link_send_per_email_rate_limiter: Arc::new( + crate::interfaces::middleware::rate_limit::RateLimiter::new( + self.config.magic_link.send_per_email_per_hour, + 3_600, + 50_000, + ), + ), + // PR 12 — per-IP backstop on /api/auth/magic-link/send. + // Bounds the damage if an attacker spreads a low per-email + // rate across many target addresses. + magic_link_send_per_ip_rate_limiter: Arc::new( + crate::interfaces::middleware::rate_limit::RateLimiter::new( + self.config.magic_link.send_per_ip_per_hour, + 3_600, + 50_000, + ), + ), }; let email_bundle = build_email_sender(&self.config.smtp); app_state.email_sender = email_bundle.sender; @@ -1316,6 +1348,26 @@ pub struct AppState { /// authenticated caller covers any legitimate UI rendering while /// throttling enumeration. pub user_profile_rate_limiter: Arc, + /// Per-sharer ceiling on `POST /api/grants` invitations whose + /// subject is `{ type: "email" }`. 50 per hour keyed on + /// `caller_id`. Anonymous attackers can't reach this code path + /// (the route is auth-protected); this defends against a + /// compromised internal account or a malicious admin. + pub email_invite_rate_limiter: Arc, + /// Per-target-email ceiling on `POST /api/auth/magic-link/send`. 5 + /// per hour keyed on the **normalised** target email. Exceeding + /// the cap is silently absorbed: the handler still returns the + /// uniform 200 anti-enumeration response, but no new mail is + /// dispatched. Authenticated callers bypass this limit. + pub magic_link_send_per_email_rate_limiter: + Arc, + /// Per-source-IP backstop on `POST /api/auth/magic-link/send`. 200 + /// per hour keyed on the trusted client IP (respects + /// `OXICLOUD_TRUST_PROXY_CIDR`). Bounds the cost of a single + /// attacker spreading 5/hr requests over a wide email list. + /// Authenticated callers bypass this limit. + pub magic_link_send_per_ip_rate_limiter: + Arc, } // All AppState construction is done via struct literal in build_app_state(). diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 6f1f4ee5..9c6b73de 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -914,9 +914,17 @@ pub struct SendMagicLinkDto { /// the absence of the entire feature is visible from any other /// endpoint touching `/api/auth/magic-link/*`. /// -/// Per-target-email rate limit is scheduled for PR 12 — without it, -/// the endpoint could be used as an email-bombing primitive against a -/// known recipient address. +/// PR 12 rate limits: +/// - **Per-source-IP**, 200/hour — bounds the cost of one attacker +/// spreading low per-email volumes over many target addresses. +/// - **Per-target-email**, 5/hour, keyed on the normalised email — +/// stops the endpoint from being an email-bombing primitive against +/// a single known recipient. +/// Both caps return the uniform 200 (never 429 to anonymous callers, +/// otherwise the status itself becomes an enumeration oracle); the +/// real reason is recorded in the audit channel. +/// Authenticated callers (Authorization header or access cookie +/// present) bypass both limits. #[utoipa::path( post, path = "/api/auth/magic-link/send", @@ -929,7 +937,7 @@ pub struct SendMagicLinkDto { )] pub async fn send_magic_link( State(state): State>, - Json(body): Json, + req: axum::http::Request, ) -> Result { let Some(invite_svc) = state.magic_link_invite_service.as_ref() else { return Err(AppError::new( @@ -939,6 +947,91 @@ pub async fn send_magic_link( )); }; + // Authentication signal — presence (not validity) of Bearer header + // OR access cookie. We deliberately don't decode the JWT here: a + // stale-cookie holder gets a 401 from any other endpoint they + // touch, and the worst-case bypass of these anti-flood caps is a + // narrow window where an attacker keeps a single expired cookie + // alive. False-negatives (a logged-in user being rate-limited + // resending to themselves) are the real cost we're avoiding. + let headers = req.headers().clone(); + let is_authenticated = headers.contains_key(axum::http::header::AUTHORIZATION) + || crate::interfaces::api::cookie_auth::extract_cookie_value( + &headers, + crate::interfaces::api::cookie_auth::ACCESS_COOKIE, + ) + .is_some(); + + let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&req); + + // Body parsing — manual because Request already consumed + // any chance of a Json extractor. 4 KiB is generous for + // `{ "email": "..." }`. + let body_bytes = axum::body::to_bytes(req.into_body(), 4 * 1024) + .await + .map_err(|_| { + AppError::new( + StatusCode::BAD_REQUEST, + "Request body too large or unreadable", + "InvalidInput", + ) + })?; + let body: SendMagicLinkDto = serde_json::from_slice(&body_bytes).map_err(|e| { + AppError::new( + StatusCode::BAD_REQUEST, + format!("Invalid JSON body: {e}"), + "InvalidInput", + ) + })?; + + let uniform_ok = || { + let payload = serde_json::json!({ + "message": "If an account exists for that email, a sign-in link will be sent.", + }); + (StatusCode::OK, Json(payload)).into_response() + }; + + if !is_authenticated { + // Per-IP backstop fires first — covers the case where an + // attacker iterates many distinct emails to spread the + // per-email budget thin. + if state + .magic_link_send_per_ip_rate_limiter + .check_and_increment(&client_ip) + .is_err() + { + tracing::warn!( + target: "audit", + event = "auth.magic_link_send", + reason = "rate_limited_ip", + ip = %client_ip, + "Per-IP rate limit exceeded on /api/auth/magic-link/send" + ); + return Ok(uniform_ok()); + } + + // Per-target-email cap, keyed on the normalised form so + // casing/IDN-host tricks don't multiply the budget. Malformed + // addresses skip this check and fall through to the service, + // which records its own audit entry under reason="malformed_email". + if let Ok(normalised) = + crate::domain::services::email_normalize::normalize_email(&body.email) + && state + .magic_link_send_per_email_rate_limiter + .check_and_increment(&normalised) + .is_err() + { + tracing::warn!( + target: "audit", + event = "auth.magic_link_send", + reason = "rate_limited_email", + ip = %client_ip, + "Per-target-email rate limit exceeded on /api/auth/magic-link/send" + ); + return Ok(uniform_ok()); + } + } + // The service swallows every operational outcome and logs the truth // via the audit channel; we surface only an internal error (DB down, // etc.). Anti-enumeration means we always return the same body. @@ -947,8 +1040,5 @@ pub async fn send_magic_link( .await .map_err(AppError::from)?; - let payload = serde_json::json!({ - "message": "If an account exists for that email, a sign-in link will be sent.", - }); - Ok((StatusCode::OK, Json(payload)).into_response()) + Ok(uniform_ok()) } diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index 34b88833..47af0069 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -116,6 +116,27 @@ pub async fn create_grant( ) .into_response(); }; + // PR 12 — per-sharer ceiling: 50 email-invitations / hour + // per caller. Hitting the cap returns 429 because the + // caller is authenticated and rate-limit visibility leaks + // nothing they don't already know about their own + // behaviour. + if state + .email_invite_rate_limiter + .check_and_increment(&caller_id.to_string()) + .is_err() + { + tracing::warn!( + target: "audit", + event = "grants.email_invite", + reason = "rate_limited", + caller_id = %caller_id, + "Per-sharer email-invite rate limit exceeded" + ); + return crate::interfaces::middleware::rate_limit::too_many_requests( + state.email_invite_rate_limiter.retry_after(), + ); + } match invite_svc.resolve_or_create_recipient(&email).await { Ok(user) => (Subject::User(user.id()), Some(user)), Err(e) => return AppError::from(e).into_response(), diff --git a/src/interfaces/middleware/rate_limit.rs b/src/interfaces/middleware/rate_limit.rs index 6835ed71..ede1fac2 100644 --- a/src/interfaces/middleware/rate_limit.rs +++ b/src/interfaces/middleware/rate_limit.rs @@ -94,7 +94,11 @@ pub fn extract_client_ip(req: &Request) -> String { } /// Build a rate-limit response with the standard `Retry-After` header. -fn too_many_requests(retry_after: u64) -> Response { +/// +/// Public so handlers that do their own (non-middleware) rate checks — +/// e.g. the email-invite branch of `POST /api/grants`, where the limit +/// only applies to one subject variant — can return the same shape. +pub fn too_many_requests(retry_after: u64) -> Response { let body = serde_json::json!({ "error": "Too many requests", "retry_after_secs": retry_after, diff --git a/tests/api/external_users.hurl b/tests/api/external_users.hurl index d26034e2..db666627 100644 --- a/tests/api/external_users.hurl +++ b/tests/api/external_users.hurl @@ -354,10 +354,88 @@ Authorization: Bearer {{alice_token}} HTTP 404 +# ───────────────────────────────────────────────────────────── +# Step 16 — Rate-limit caps (PR 12). Test-only thresholds come +# from tests/common/server.env: +# OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=3 +# OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=2 +# Alice already burned 2 invite slots earlier (bob's +# folder + ext-share-2) and 1 send slot in Step 15a. +# ───────────────────────────────────────────────────────────── + +# 16a — Alice's 3rd email-invite (3/3) succeeds — right at the +# cap. Fresh email so resolve_or_create_recipient mints a +# new external user we'll clean up below. +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "email", "email": "ratelimit-test-1@externalcompany.com" }, + "resource": { "type": "folder", "id": "{{ext_folder_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Captures] +rl_user_1_id: jsonpath "$[0].subject.id" + +# 16b — 4th invite (4/3) is rejected with 429 + Retry-After. The +# cap is visible because Alice is authenticated and her own +# rate-limit state leaks nothing about other accounts. +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "email", "email": "ratelimit-test-2@externalcompany.com" }, + "resource": { "type": "folder", "id": "{{ext_folder_id}}" }, + "role": "viewer" +} + +HTTP 429 +[Asserts] +header "retry-after" exists +jsonpath "$.retry_after_secs" >= 1 + +# 16c — Anonymous /magic-link/send to bob (2/2 — at cap). Returns +# the same uniform 200 a successful issuance would; the +# audit log distinguishes the two. +POST {{base_url}}/api/auth/magic-link/send +Content-Type: application/json +{ "email": "bob@externalcompany.com" } + +HTTP 200 +[Asserts] +jsonpath "$.message" contains "sign-in link" + +# 16d — 3rd anonymous send to bob (3/2 — over cap). Anti-enumeration: +# must NOT return 429, must NOT change the response shape. +POST {{base_url}}/api/auth/magic-link/send +Content-Type: application/json +{ "email": "bob@externalcompany.com" } + +HTTP 200 +[Asserts] +jsonpath "$.message" contains "sign-in link" + +# 16e — Authenticated callers bypass both anti-flood caps. Alice +# resends to bob with her Bearer token; the per-email and +# per-IP counters are not consulted (a logged-in user +# resending should never be throttled). Still returns 200. +POST {{base_url}}/api/auth/magic-link/send +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "email": "bob@externalcompany.com" } + +HTTP 200 +[Asserts] +jsonpath "$.message" contains "sign-in link" + + # ───────────────────────────────────────────────────────────── # Step 12 — Cleanup. Alice trashes the two test folders and -# deletes bob via the admin API so the suite's -# storage-check sweep at run.sh end sees a clean DB. +# deletes bob + the two rate-limit-test externals via +# the admin API so the suite's storage-check sweep at +# run.sh end sees a clean DB. # ───────────────────────────────────────────────────────────── DELETE {{base_url}}/api/folders/{{ext_folder_id_2}} Authorization: Bearer {{alice_token}} @@ -373,3 +451,8 @@ DELETE {{base_url}}/api/admin/users/{{bob_user_id}} Authorization: Bearer {{alice_token}} HTTP * + +DELETE {{base_url}}/api/admin/users/{{rl_user_1_id}} +Authorization: Bearer {{alice_token}} + +HTTP * diff --git a/tests/common/server.env b/tests/common/server.env index aea2ebe7..f4f37a83 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -34,3 +34,10 @@ OXICLOUD_SMTP_PORT=25 OXICLOUD_SMTP_FROM='OxiCloud Tests ' OXICLOUD_SMTP_TLS=none OXICLOUD_ALLOW_EXTERNAL_USERS=true + +# 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). +OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=3 +OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=2 +OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=50