From 372b99890f6482062403c1bcb16b6231ccb5ebe4 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 3 Jun 2026 11:13:43 +0200 Subject: [PATCH] feat(magic-link): user can resend email if magic link is expired --- docs/architecture/magic-link-auth.md | 36 ++- docs/guide/sharing.md | 7 +- .../services/magic_link_invite_service.rs | 96 +++++++- .../api/handlers/magic_link_handler.rs | 217 +++++++++++++++++- 4 files changed, 345 insertions(+), 11 deletions(-) diff --git a/docs/architecture/magic-link-auth.md b/docs/architecture/magic-link-auth.md index c289c178..ba7c57c7 100644 --- a/docs/architecture/magic-link-auth.md +++ b/docs/architecture/magic-link-auth.md @@ -105,6 +105,38 @@ Salient properties: - **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. +- **Rows persist past `used` / `expired`** — the sweeper transitions status, it does not immediately DELETE. This is what makes the self-service resend (next section) possible: the token in the URL keeps working as a recipient-discovery key well after the credential it carried has stopped working. + +## Self-service resend + +The 410-Gone landing page for a stale link is **not a dead end**. The page server-side branches on whether the token row is recoverable: + +- Row exists, status is `expired` **or** `used`, owning user is still active → the page renders a one-click form: *"Send a fresh link to a…@example.com"* (POST to `/magic/v1/{token}/resend`). +- Anything else (unknown token, pending, deactivated account, plumbing missing) → the page falls back to the existing generic "no longer valid" message. The two responses are deliberately indistinguishable to the caller — the rich page only differs when the row already proves the caller has legitimate context. + +### Why no PII in the URL + +An earlier sketch carried the recipient's email as `?r={base64(email)}` so the page could greet the user by address. We dropped it: a token is short-lived but a URL persists in browser history forever (and syncs to Chrome / Firefox cloud profiles), the address would leak via any future external Referer, and reverse-proxy access logs would gain a PII field they don't have today. Since the row already carries `user_id → users.email`, the server can recover the address on demand and the URL stays clean. + +### Why both `expired` and `used` qualify + +`used` covers the "I clicked the link on my phone, now I want to sign in on my laptop" case — the original link is dead by single-use design, but the recipient is real and the row still holds the recipient pointer. Offering resend on `used` is harmless (the new mail goes to the registered email, not the caller; rate limits are the same) and avoids a confusing dead-end for the most common second-device path. + +### Endpoint shape + +`POST /magic/v1/{token}/resend` mirrors `POST /api/auth/magic-link/send` in every operational respect: + +1. **Per-source-IP rate limit** (`OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR`, default 200/h) — runs first, unconditionally. Burns budget even when the token doesn't resolve so the endpoint can't be used to spread probes thin across many tokens. +2. **Token resolution** — `MagicLinkInviteService::lookup_resend_recipient(token)`. Returns `Some(ResendRecipientHint)` only for `expired` / `used` rows whose owning user is active. `None` in every other case (pending, unknown, deactivated, repo absent). +3. **Per-target-email rate limit** (`OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR`, default 5/h) — keyed on the recipient we just resolved. Caps actual mail volume to the recipient regardless of how many IPs hammer the endpoint; effectively a per-token-recipient ceiling without a schema-level counter. +4. **Fresh challenge + send** — generate a per-request challenge (cookie + token-row mirror, see PR 22), dispatch through `send_login_link`. The new token has the standard short login TTL, not the longer invite TTL — the recipient just clicked, so a slow second click is almost certainly someone else with mailbox access. +5. **Uniform response** — every outcome (rate-limited, no-account, account deactivated, SMTP-failed, succeeded) renders the same "Check your inbox" HTML page. The real outcome is in the audit channel via `auth.magic_link_send` events. + +The handler is a sibling under the same `/magic/v1/*` router, **no CSRF middleware** (the page that triggers it is the 410 response itself — same-origin, plain HTML form, no JS, no third-party referrer realistically able to forge the POST against a per-token URL). + +### What the per-token ceiling looks like in practice + +A single recovered URL × per-target-email cap (5/h) × 24h = **120 magic-link mails per day maximum** to that recipient from that specific URL. Annoying but bounded; the recipient's inbox cannot be flooded into uselessness from one stable handle. A per-token counter column (`resend_count` with a hard maximum, e.g. 3) would tighten the bound further; it's deferred until real abuse is observed. ## User-profile visibility rule (`GET /api/users/{id}`) @@ -127,7 +159,7 @@ Every denial or rejection in the magic-link path emits a structured event on the |------------------------------------|---------------------------------------------------------------------------------------------------|----------------------------------------------------------| | `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_send` | `sent`, `no_account`, `has_credential`, `account_deactivated`, `malformed_email`, `rate_limited_ip`, `rate_limited_email`, `internal_error` | `MagicLinkInviteService::send_login_link`, `auth_handler::send_magic_link`, `magic_link_handler::resend_magic_link` | | `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` | @@ -148,6 +180,8 @@ Three caps protect the magic-link surface. Each is a moka sliding-window counter | 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) | +The two send caps are shared between `POST /api/auth/magic-link/send` and `POST /magic/v1/{token}/resend` — they're the same moka counters, so an attacker can't double their budget by alternating endpoints. + 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. diff --git a/docs/guide/sharing.md b/docs/guide/sharing.md index 956f0347..0ebbb7cc 100644 --- a/docs/guide/sharing.md +++ b/docs/guide/sharing.md @@ -47,8 +47,11 @@ When you enter someone's email address, OxiCloud sends them a message with a sign-in link. Clicking that link signs them in and opens the file or folder you shared — no password to create, no form to fill in. -The link expires after a day. If they miss it, you can resend it from -the **My shares** section. +The link expires after a day, and works only once. If the recipient +clicks an old or already-used link, the page they land on offers a +**"Send a fresh link"** button — one click and a new sign-in link is +on its way to their inbox. They can also ask you to resend from your +**My shares** section. ## Keeping track — *My shares* diff --git a/src/application/services/magic_link_invite_service.rs b/src/application/services/magic_link_invite_service.rs index dcf909f1..10175a3f 100644 --- a/src/application/services/magic_link_invite_service.rs +++ b/src/application/services/magic_link_invite_service.rs @@ -35,7 +35,9 @@ use crate::application::ports::email_sender::{EmailMessage, EmailSender}; use crate::application::services::user_lifecycle_service::UserLifecycleService; use crate::common::config::MagicLinkConfig; use crate::common::errors::{DomainError, ErrorKind}; -use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkToken}; +use crate::domain::entities::magic_link_token::{ + MagicLinkResourceKind, MagicLinkStatus, MagicLinkToken, +}; use crate::domain::entities::user::{User, UserRole}; use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository; use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError}; @@ -495,6 +497,80 @@ impl MagicLinkInviteService { Ok(()) } + + /// Look up the resend-recipient hint for a token whose redemption + /// just failed. Returns `Some` exactly when: + /// + /// 1. the token row exists, + /// 2. its status is `Expired` (TTL elapsed) or `Used` (already + /// redeemed once — recipient may be re-clicking on a different + /// device), and + /// 3. the owning user account is still active. + /// + /// Returns `None` (no resend offered) for `Pending` tokens, unknown + /// tokens, and deactivated accounts. The `None` branches deliberately + /// look identical to the caller — anyone who can present a valid + /// token already has its access semantics, so the only "oracle" + /// surface is "did this token exist in some non-pending state", + /// which is moot. + pub async fn lookup_resend_recipient( + &self, + token: &str, + ) -> Result, DomainError> { + let Some(mlt) = self.magic_link_repo.find_by_token(token).await? else { + return Ok(None); + }; + // Pending tokens are still redeemable — no reason to offer a + // resend. The user should just click the original link again. + if !matches!( + mlt.status(), + MagicLinkStatus::Expired | MagicLinkStatus::Used + ) { + return Ok(None); + } + let user = match UserRepository::get_user_by_id(&*self.user_storage, mlt.user_id()).await { + Ok(u) => u, + Err(UserRepositoryError::NotFound(_)) => return Ok(None), + Err(e) => return Err(DomainError::from(e)), + }; + if !user.is_active() { + return Ok(None); + } + let email = user.email().to_string(); + let masked_email = mask_email(&email); + Ok(Some(ResendRecipientHint { + email, + masked_email, + })) + } +} + +/// Hint surfaced by the 410-Gone page to offer a one-click "send me a +/// fresh link" affordance to a recipient whose magic-link is no longer +/// usable. Carries the recipient's email twice: the raw form (used by +/// the resend handler to dispatch the new mail) and a masked form +/// (rendered into the HTML page so the user can confirm the destination +/// without the full address being plastered in the URL or address bar). +#[derive(Debug, Clone)] +pub struct ResendRecipientHint { + pub email: String, + pub masked_email: String, +} + +/// Mask an email for display: keep the first character of the local +/// part, then `…`, then the full domain. `alice@example.com` → +/// `a…@example.com`. Short local parts (1 char) collapse to just +/// `…@domain`. Malformed input (no `@`) is masked entirely as `…`. +pub fn mask_email(email: &str) -> String { + match email.rsplit_once('@') { + Some((local, domain)) if !local.is_empty() => { + let mut chars = local.chars(); + let first = chars.next().unwrap_or('?'); + format!("{first}…@{domain}") + } + Some((_, domain)) => format!("…@{domain}"), + None => "…".to_string(), + } } /// Lightweight conversion so the grant handler can derive a @@ -545,6 +621,24 @@ mod tests { ); } + #[test] + fn mask_email_keeps_one_char_of_local() { + assert_eq!(mask_email("alice@example.com"), "a…@example.com"); + assert_eq!(mask_email("very-long-name@example.com"), "v…@example.com"); + } + + #[test] + fn mask_email_handles_edge_cases() { + // Single-char local part still leaks just the first char, by + // design — same masking rule applies uniformly so the output + // shape itself doesn't disclose local-part length. + assert_eq!(mask_email("a@b.co"), "a…@b.co"); + // Malformed (no `@`) is masked entirely. + assert_eq!(mask_email("not-an-email"), "…"); + // Pathological (starts with `@`) collapses the empty local. + assert_eq!(mask_email("@example.com"), "…@example.com"); + } + #[test] fn password_user_strict_then_lenient() { let u = user(Some("$argon2id$..."), None); diff --git a/src/interfaces/api/handlers/magic_link_handler.rs b/src/interfaces/api/handlers/magic_link_handler.rs index 6f1c06e2..6d5608e8 100644 --- a/src/interfaces/api/handlers/magic_link_handler.rs +++ b/src/interfaces/api/handlers/magic_link_handler.rs @@ -28,23 +28,33 @@ use axum::{ extract::{Path, Query, State}, http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE, header::LOCATION}, response::{IntoResponse, Response}, - routing::get, + routing::{get, post}, }; use serde::Deserialize; use crate::application::services::auth_application_service::{ MagicLinkRedeemResult, MagicLinkRedemption, }; +use crate::application::services::magic_link_invite_service::ResendRecipientHint; use crate::common::di::AppState; use crate::common::errors::ErrorKind; use crate::domain::entities::magic_link_token::MagicLinkResourceKind; use crate::interfaces::api::cookie_auth; +use crate::interfaces::middleware::rate_limit::extract_client_ip; /// Build the `/magic/v1/{token}` router. Mounted at the top of the /// application tree in `main.rs` — no auth middleware, no CSRF (the /// token is the credential, the route is GET-only). +/// +/// `POST /magic/v1/{token}/resend` is a sibling endpoint that lets the +/// 410-Gone page offer a one-click "send me a fresh link" button. The +/// recipient email is looked up server-side from the (expired/used) +/// token row — no PII in the URL — and the rate limits attached to +/// `POST /api/auth/magic-link/send` apply identically. pub fn magic_link_routes() -> Router> { - Router::new().route("/magic/v1/{token}", get(redeem_magic_link)) + Router::new() + .route("/magic/v1/{token}", get(redeem_magic_link)) + .route("/magic/v1/{token}/resend", post(resend_magic_link)) } #[derive(Debug, Deserialize)] @@ -124,11 +134,9 @@ async fn redeem_magic_link( StatusCode::SERVICE_UNAVAILABLE, "Magic-link sign-in is not enabled on this server.", ), - ErrorKind::NotFound | ErrorKind::AccessDenied => error_page( - StatusCode::GONE, - "This sign-in link is no longer valid. It may have already been \ - used or expired. Request a fresh link from the login page.", - ), + ErrorKind::NotFound | ErrorKind::AccessDenied => { + expired_or_used_page(&state, &token).await + } _ => error_page( StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong while signing you in. Please try again.", @@ -138,6 +146,201 @@ async fn redeem_magic_link( } } +/// `POST /magic/v1/{token}/resend` — one-click handler behind the +/// "Send a fresh link" button on the 410-Gone page. +/// +/// The token in the URL serves as a *recipient-discovery key*, never as +/// a credential: the server looks up the (expired or used) row, walks +/// to the owning user, and dispatches a fresh login-via-email magic- +/// link to that user's email. The endpoint never trusts client-supplied +/// email and never echoes the resolved address back, so the resend +/// URL is safe to leave in browser history. +/// +/// Anti-abuse: +/// - **Per-source-IP** (200/h, shared with `/api/auth/magic-link/send`) +/// bounds burst from a single attacker. +/// - **Per-target-email** (5/h, also shared) caps actual mail volume +/// to the recipient regardless of how many IPs hammer the endpoint. +/// - **Uniform response** on every outcome — rate-limited, no-account, +/// SMTP-failed, succeeded — so the page shape is not an oracle. +/// - **Audit log** carries the truth via the `auth.magic_link_send` +/// events emitted by `MagicLinkInviteService::send_login_link`. +async fn resend_magic_link( + State(state): State>, + Path(token): Path, + req: axum::http::Request, +) -> Response { + let confirmation = || { + resend_confirmation_page( + "If the sign-in link belonged to an active account, a fresh \ + link has just been sent. Please check your inbox.", + ) + }; + + let Some(invite_svc) = state.magic_link_invite_service.as_ref() else { + return error_page( + StatusCode::SERVICE_UNAVAILABLE, + "Magic-link sign-in is not enabled on this server.", + ); + }; + + let client_ip = extract_client_ip(&req); + + // Per-IP backstop runs unconditionally — burns through the budget + // even when the token doesn't resolve, so the endpoint can't be + // used to spread probes thin across many tokens. + 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 /magic/v1/{{token}}/resend" + ); + return confirmation(); + } + + let hint = match invite_svc.lookup_resend_recipient(&token).await { + Ok(Some(h)) => h, + _ => { + // Unknown / pending / deactivated — uniform response so the + // outcome is not an oracle for "is this a known token". + return confirmation(); + } + }; + + // Per-target-email cap — keyed on the recipient we just resolved. + // Locks the mail volume to a single recipient regardless of how + // many distinct IPs the attacker spreads across. + if state + .magic_link_send_per_email_rate_limiter + .check_and_increment(&hint.email) + .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 /magic/v1/{{token}}/resend" + ); + return confirmation(); + } + + let challenge = cookie_auth::generate_magic_request_challenge(); + let login_ttl_secs = (state.core.config.magic_link.login_ttl_minutes * 60) as i64; + + // Service swallows operational outcomes and audits the truth; we + // surface only DB / unexpected errors as 500. + if let Err(e) = invite_svc.send_login_link(&hint.email, &challenge).await { + tracing::error!( + target: "audit", + event = "auth.magic_link_send", + reason = "internal_error", + error = %e.message, + "Resend dispatch failed for an unexpected reason" + ); + return error_page( + StatusCode::INTERNAL_SERVER_ERROR, + "Something went wrong while sending the link. Please try again.", + ); + } + + let mut response = confirmation(); + cookie_auth::append_magic_request_cookie(response.headers_mut(), &challenge, login_ttl_secs); + response +} + +/// Plain HTML confirmation rendered after the resend button is clicked. +/// Same shape on every outcome — see [`resend_magic_link`] for why. +fn resend_confirmation_page(message: &str) -> Response { + let body = format!( + "OxiCloud\ + \ + \ +

Check your inbox

\ +

{}

\ +

Return to OxiCloud

\ + ", + html_escape(message) + ); + let mut response = (StatusCode::OK, body).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + response +} + +/// Render the 410-Gone landing for a token that's either expired, +/// already used, or unknown. When the token belongs to an active user +/// (i.e. the redemption failed because the row exists but is past its +/// useful state), the page carries a one-click "send a fresh link" +/// form pre-targeted at the recipient's masked email. When the token +/// is unknown, the user is deactivated, or any plumbing is missing, +/// the page falls back to the existing generic message — by design +/// the two responses are indistinguishable to the caller. +async fn expired_or_used_page(state: &Arc, token: &str) -> Response { + let generic = || { + error_page( + StatusCode::GONE, + "This sign-in link is no longer valid. It may have already been \ + used or expired. Request a fresh link from the login page.", + ) + }; + + let Some(invite_svc) = state.magic_link_invite_service.as_ref() else { + return generic(); + }; + let hint = match invite_svc.lookup_resend_recipient(token).await { + Ok(Some(h)) => h, + _ => return generic(), + }; + resend_offer_page(token, &hint) +} + +/// HTML body for the enriched 410 page: explains the outcome and +/// offers a single-button form posting to `POST /magic/v1/{token}/resend`. +/// Plain HTML — no JavaScript, no external assets — so it works the +/// same in any mail-client embedded browser. The form action is the +/// only place the token round-trips; the masked email is the only +/// thing rendered, so screenshots / shoulder-surfing don't expose the +/// full address. +fn resend_offer_page(token: &str, hint: &ResendRecipientHint) -> Response { + let action = format!("/magic/v1/{}/resend", html_escape(token)); + let masked = html_escape(&hint.masked_email); + let body = format!( + "OxiCloud\ + \ + \ +

This sign-in link is no longer valid

\ +

The link may have expired or already been used. \ + We can send you a fresh one — it'll arrive in your inbox in a few seconds.

\ +
\ + \ +
\ +

Return to OxiCloud

\ + " + ); + let mut response = (StatusCode::GONE, body).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + response +} + fn build_success_response(state: &Arc, redemption: MagicLinkRedemption) -> Response { let target = redirect_target(&redemption);