diff --git a/src/application/services/magic_link_invite_service.rs b/src/application/services/magic_link_invite_service.rs index f730f16e..eb5d38a1 100644 --- a/src/application/services/magic_link_invite_service.rs +++ b/src/application/services/magic_link_invite_service.rs @@ -249,6 +249,152 @@ impl MagicLinkInviteService { } } } + + /// Login-via-email flow (PR 10). Caller submits an email at + /// `/login`; we look it up — **never lazy-create** here, that path + /// is reserved for `resolve_or_create_recipient` — and if the + /// matched user has no other login credential, mint a NULL-resource + /// magic-link token and email a sign-in link. The redemption + /// endpoint lands a NULL-resource token on `/#/sharedwithme`. + /// + /// Always returns `Ok(())` so the caller can emit a uniform + /// response shape (`"If an account exists, a link will be sent."`) + /// that doesn't reveal whether the email maps to an account. + /// + /// Audit log distinguishes three real outcomes — `sent`, + /// `no_account`, `has_credential` — so operators can see the truth + /// while the API stays anti-enumeration-safe. A fourth outcome + /// `send_failed` is logged at `warn` level when SMTP errors. + pub async fn send_login_link(&self, raw_email: &str) -> Result<(), DomainError> { + let normalised = match normalize_email(raw_email) { + Ok(n) => n, + Err(e) => { + // Malformed input is treated the same as "no account" + // — uniform response, no oracle from validation errors. + tracing::info!( + target: "audit", + event = "auth.magic_link_send", + reason = "malformed_email", + error = %e, + "🔗 login-link suppressed: malformed email", + ); + return Ok(()); + } + }; + + let user = match UserRepository::get_user_by_email(&*self.user_storage, &normalised).await { + Ok(u) => u, + Err(UserRepositoryError::NotFound(_)) => { + tracing::info!( + target: "audit", + event = "auth.magic_link_send", + reason = "no_account", + email = %normalised, + "🔗 login-link suppressed: no account for '{}'", + normalised, + ); + return Ok(()); + } + Err(e) => return Err(DomainError::from(e)), + }; + + if user.has_login_credential() { + // Refuse the magic-link path for users with a password / + // OIDC — accepting it would let an attacker bypass those + // factors by merely owning the mailbox at the moment of + // request. They should sign in through the regular form. + tracing::info!( + target: "audit", + event = "auth.magic_link_send", + reason = "has_credential", + user_id = %user.id(), + username = %user.username(), + email = %normalised, + "🔗 login-link suppressed: '{}' has another login credential", + user.username(), + ); + return Ok(()); + } + + if !user.is_active() { + tracing::info!( + target: "audit", + event = "auth.magic_link_send", + reason = "account_deactivated", + user_id = %user.id(), + username = %user.username(), + email = %normalised, + "🔗 login-link suppressed: account deactivated for '{}'", + user.username(), + ); + return Ok(()); + } + + // Mint a NULL-resource token. The redemption handler lands + // NULL-resource tokens on /#/sharedwithme (see PR 8). + let token = MagicLinkToken::new(user.id(), self.magic_link_cfg.ttl_hours, None); + self.magic_link_repo.create(&token).await?; + + let link = format!( + "{}/magic/v1/{}", + self.public_base_url.trim_end_matches('/'), + token.token(), + ); + let subject = "Sign in to OxiCloud".to_string(); + let text_body = format!( + "Hello,\n\ + \n\ + Use the link below to sign in to OxiCloud. The link works \ + once and expires in {ttl} hours.\n\ + \n\ + {link}\n\ + \n\ + If you didn't request this sign-in link, you can safely \ + ignore this message — no further action is needed.\n\ + \n\ + — OxiCloud, {now}\n", + ttl = self.magic_link_cfg.ttl_hours, + link = link, + now = Utc::now().to_rfc3339(), + ); + + let message = EmailMessage { + to: user.email().to_string(), + subject, + text_body, + html_body: None, + }; + + match self.email_sender.send(message).await { + Ok(outcome) => { + tracing::info!( + target: "audit", + event = "auth.magic_link_send", + reason = "sent", + user_id = %user.id(), + username = %user.username(), + email = %normalised, + smtp_code = outcome.code, + smtp_message = %outcome.message, + "🔗 login-link sent to '{}'", + normalised, + ); + } + Err(e) => { + tracing::warn!( + target: "audit", + event = "auth.magic_link_send_failed", + user_id = %user.id(), + email = %normalised, + error = %e.message, + "🔗 login-link SMTP send failed for '{}'", + normalised, + ); + } + } + + Ok(()) + } } /// Lightweight conversion so the grant handler can derive a diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index afb37d66..6f1f4ee5 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -29,6 +29,9 @@ pub fn auth_public_routes() -> Router> { .route("/oidc/authorize", get(oidc_authorize)) .route("/oidc/callback", get(oidc_callback)) .route("/oidc/exchange", post(oidc_exchange)) + // Login-via-email — sends a magic-link to the user's email so + // accounts with no other login credential can sign in. + .route("/magic-link/send", post(send_magic_link)) } /// Protected auth routes — require authentication (auth + CSRF middleware @@ -890,3 +893,62 @@ pub async fn oidc_exchange( cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in); Ok(response) } + +/// Request body for `POST /api/auth/magic-link/send`. +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct SendMagicLinkDto { + pub email: String, +} + +/// POST /api/auth/magic-link/send — request a sign-in link by email. +/// +/// Always returns 200 with a uniform message regardless of outcome, so +/// the response shape doesn't leak account existence. The real outcome +/// (sent / no-account / has-credential / account-deactivated / +/// malformed-email) is recorded in the `audit` channel via +/// `MagicLinkInviteService::send_login_link`. +/// +/// 503 only when the magic-link feature isn't configured at all +/// (SMTP env missing) — operators need to know about misconfiguration; +/// it's not a state an anonymous caller can probe via timing because +/// 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. +#[utoipa::path( + post, + path = "/api/auth/magic-link/send", + request_body = SendMagicLinkDto, + responses( + (status = 200, description = "Uniform 'if an account exists, a link will be sent' response"), + (status = 503, description = "Magic-link / SMTP is not configured on this server"), + ), + tag = "auth", +)] +pub async fn send_magic_link( + State(state): State>, + Json(body): Json, +) -> Result { + let Some(invite_svc) = state.magic_link_invite_service.as_ref() else { + return Err(AppError::new( + StatusCode::SERVICE_UNAVAILABLE, + "Magic-link sign-in is not configured on this server", + "ServiceUnavailable", + )); + }; + + // 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. + invite_svc + .send_login_link(&body.email) + .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()) +} diff --git a/static/css/views/auth.css b/static/css/views/auth.css index 7ae672a6..241e26dc 100644 --- a/static/css/views/auth.css +++ b/static/css/views/auth.css @@ -183,6 +183,36 @@ font-size: 14px; } +/* Helper text above the magic-link form ("No password? Enter your + email…"). Quieter visual weight than the form labels. */ +.auth-hint { + margin: 0 0 12px; + font-size: 13px; + line-height: 1.4; + color: var(--color-text-secondary); +} + +/* Status banner under the magic-link form. Uniform anti-enumeration + message rendered on every successful 2xx; error variant only used + for the 503-not-configured branch or network failures. */ +.auth-status { + margin-top: 12px; + padding: 10px 14px; + border-radius: 8px; + font-size: 13px; + line-height: 1.4; +} +.auth-status-success { + background: var(--color-bg-hover); + color: var(--color-text); + border-left: 3px solid var(--color-warning-orange-text); +} +.auth-status-error { + background: var(--color-bg-hover); + color: var(--color-text); + border-left: 3px solid var(--color-warning-orange-text); +} + /* Divider between password and SSO login */ .auth-divider { display: flex; diff --git a/static/js/features/auth/auth.js b/static/js/features/auth/auth.js index f467f364..9d2a4276 100644 --- a/static/js/features/auth/auth.js +++ b/static/js/features/auth/auth.js @@ -1072,6 +1072,54 @@ if (isLoginPage && registerForm) { } // Admin setup form submission +// Magic-link form: anti-enumeration sign-in by email. +// The server always responds 200 with a uniform message when SMTP is +// configured, regardless of whether the email maps to an account or +// whether that account is eligible for magic-link sign-in. The UI +// mirrors that — same success state for every successful 2xx — so +// the page can't be used as an oracle. 503 is the one exception +// (SMTP not configured); operators need to see it. +const magicLinkForm = /** @type {HTMLFormElement | null} */ (document.getElementById('magic-link-form')); +const magicLinkStatus = document.getElementById('magic-link-status'); +const magicLinkSubmit = /** @type {HTMLButtonElement | null} */ (document.getElementById('magic-link-submit')); +if (isLoginPage && magicLinkForm && magicLinkStatus) { + magicLinkForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const email = inputVal('magic-link-email'); + if (!email) return; + + magicLinkStatus.className = 'auth-status hidden'; + magicLinkStatus.textContent = ''; + if (magicLinkSubmit) magicLinkSubmit.disabled = true; + + try { + const resp = await fetch('/api/auth/magic-link/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, + body: JSON.stringify({ email }) + }); + if (resp.status === 503) { + magicLinkStatus.className = 'auth-status auth-status-error'; + magicLinkStatus.textContent = i18n.t('auth.magicLinkUnavailable', 'Sign-in by email is not available on this server.'); + return; + } + // Any 2xx → uniform success message regardless of whether + // the server actually queued a mail. Anti-enumeration. + magicLinkStatus.className = 'auth-status auth-status-success'; + magicLinkStatus.textContent = i18n.t('auth.magicLinkSent', 'If an account exists for that email, a sign-in link has been sent. Check your inbox.'); + magicLinkForm.reset(); + } catch (err) { + magicLinkStatus.className = 'auth-status auth-status-error'; + magicLinkStatus.textContent = i18n.t('auth.magicLinkNetworkError', { + message: /** @type {Error} */ (err).message + }); + } finally { + if (magicLinkSubmit) magicLinkSubmit.disabled = false; + magicLinkStatus.classList.remove('hidden'); + } + }); +} + if (isLoginPage && adminSetupForm) { adminSetupForm.addEventListener('submit', async (e) => { e.preventDefault(); diff --git a/static/locales/en.json b/static/locales/en.json index cc4dda02..f390a653 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -412,7 +412,14 @@ "admin_create_error": "Error creating administrator account", "or": "or", "sso_login": "Sign in with SSO", - "sso_login_provider": "Sign in with {{provider}}" + "sso_login_provider": "Sign in with {{provider}}", + "magicLinkHint": "No password? Enter your email and we'll send you a one-time sign-in link.", + "magicLinkEmailLabel": "Email address", + "magicLinkEmailPlaceholder": "you@example.com", + "magicLinkSubmit": "Send sign-in link", + "magicLinkSent": "If an account exists for that email, a sign-in link has been sent. Check your inbox.", + "magicLinkUnavailable": "Sign-in by email is not available on this server.", + "magicLinkNetworkError": "Could not reach the server: {{message}}" }, "storage": { "title": "Storage", diff --git a/static/locales/fr.json b/static/locales/fr.json index 1433798a..334d62bc 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -412,7 +412,14 @@ "admin_create_error": "Erreur lors de la création du compte administrateur", "or": "ou", "sso_login": "Se connecter avec SSO", - "sso_login_provider": "Se connecter avec {{provider}}" + "sso_login_provider": "Se connecter avec {{provider}}", + "magicLinkHint": "Pas de mot de passe ? Saisissez votre adresse e-mail et nous vous enverrons un lien de connexion à usage unique.", + "magicLinkEmailLabel": "Adresse e-mail", + "magicLinkEmailPlaceholder": "vous@exemple.com", + "magicLinkSubmit": "Envoyer le lien de connexion", + "magicLinkSent": "Si un compte existe pour cette adresse, un lien de connexion vient d'être envoyé. Consultez votre boîte de réception.", + "magicLinkUnavailable": "La connexion par e-mail n'est pas disponible sur ce serveur.", + "magicLinkNetworkError": "Impossible de joindre le serveur : {{message}}" }, "storage": { "title": "Stockage", diff --git a/static/login.html b/static/login.html index c8a9032a..49752b01 100644 --- a/static/login.html +++ b/static/login.html @@ -108,6 +108,42 @@ Sign in with SSO + + +
Don't have an account? diff --git a/tests/api/external_users.hurl b/tests/api/external_users.hurl index 4fec89a7..d26034e2 100644 --- a/tests/api/external_users.hurl +++ b/tests/api/external_users.hurl @@ -274,6 +274,86 @@ GET {{magic_url}} HTTP 410 +# ───────────────────────────────────────────────────────────── +# Step 15 — Login-via-email (PR 10). Bob has no password (he was +# lazily provisioned via the invite flow), so he is +# magic-link-eligible. He requests a fresh sign-in link. +# Anti-enumeration: the API always returns 200 with the +# same body regardless of whether an account exists. +# ───────────────────────────────────────────────────────────── + +# 15a — bob requests a sign-in link. +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" + +# 15b — Capture the fresh email; extract the NEW magic-link URL. +# This is a NULL-resource token (login flow), so redemption +# will land on /#/sharedwithme rather than a deep-link. +GET {{base_url}}/api/admin/smtp/test/captured?to=bob@externalcompany.com +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$.subject" contains "Sign in" +jsonpath "$.text_body" matches "/magic/v1/[A-Za-z0-9_-]+" +[Captures] +login_magic_url: jsonpath "$.text_body" regex "(https?://[^\\s]+/magic/v1/[A-Za-z0-9_-]+)" + +# 15c — Redeem the login link. Lands on /#/sharedwithme since the +# token has no resource target. +GET {{login_magic_url}} + +HTTP 302 +[Asserts] +header "Location" == "/#/sharedwithme" +[Captures] +bob_relogin_token: cookie "oxicloud_access" + +# 15d — Bob's new session works: he can read his incoming grants. +GET {{base_url}}/api/grants/incoming/resources +Authorization: Bearer {{bob_relogin_token}} + +HTTP 200 + +# 15e — Unknown email → same uniform 200 (anti-enumeration). No +# mail is captured under that address. +POST {{base_url}}/api/auth/magic-link/send +Content-Type: application/json +{ "email": "nobody-here@externalcompany.com" } + +HTTP 200 +[Asserts] +jsonpath "$.message" contains "sign-in link" + +GET {{base_url}}/api/admin/smtp/test/captured?to=nobody-here@externalcompany.com +Authorization: Bearer {{alice_token}} + +HTTP 404 + +# 15f — Email maps to an existing internal user with a password +# (Alice the admin) → uniform 200 but the magic link is NOT +# actually sent. has_login_credential() short-circuits the +# service so password/OIDC accounts cannot be bypassed via +# mailbox ownership at the moment of request. +POST {{base_url}}/api/auth/magic-link/send +Content-Type: application/json +{ "email": "{{email}}" } + +HTTP 200 +[Asserts] +jsonpath "$.message" contains "sign-in link" + +GET {{base_url}}/api/admin/smtp/test/captured?to={{email}} +Authorization: Bearer {{alice_token}} + +HTTP 404 + + # ───────────────────────────────────────────────────────────── # Step 12 — Cleanup. Alice trashes the two test folders and # deletes bob via the admin API so the suite's