test(login/register): via password or magic-link

Password login

┌─────┬────────────────────────────────────────────────────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐
│  #  │                        Case                        │         Where          │                                          Assertion                                          │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L1  │ Login by username                                  │ auth_login.hurl Case 1 │ 200 + access_token, user.email match                                                        │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L2  │ Login by email (dispatch on @)                     │ auth_login.hurl Case 2 │ 200, same session shape as L1                                                               │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L3  │ Bad password on username path                      │ auth_login.hurl Case 3 │ 403 anti-enum                                                                               │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L4  │ Bad password on email path                         │ auth_login.hurl Case 4 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L5  │ Unknown username                                   │ auth_login.hurl Case 5 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L6  │ Unknown email                                      │ auth_login.hurl Case 6 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L7  │ /api/auth/oidc/providers reports methods correctly │ auth_login.hurl Case 7 │ password_login_enabled: true, magic_link_login_enabled: true, require_verified_email: false │
└─────┴────────────────────────────────────────────────────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘

Password registration

┌─────┬───────────────────────────────────────────────────┬──────────────────────────────┬─────────────────────────────────────────────────────────┐
│  #  │                       Case                        │            Where             │                        Assertion                        │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R1  │ Classic username + email + password → uniform 200 │ registration.hurl Step 2     │ anti-enum message contains "request received"           │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R2  │ Login after register works                        │ registration.hurl Step 2b    │ 200 + session for the new user                          │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R3  │ Email collision → uniform 200 (no rewrite)        │ registration.hurl Steps 8-10 │ attacker password doesn't work; original account intact │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R4  │ Username collision → uniform 200                  │ registration.hurl Step 11    │ same anti-enum shape                                    │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R5  │ Off-domain rejection                              │ registration.hurl Step 12    │ 403 RegistrationDomainNotAllowed                        │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R6  │ Case-insensitive domain match                     │ registration.hurl Step 12b   │ uniform 200 on charlie@EXAMPLE.COM                      │
└─────┴───────────────────────────────────────────────────┴──────────────────────────────┴────────────────────────────┘

Magic-link registration (email-only signup)

┌─────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐
│  #  │                                               Case                                               │             Where             │                   Assertion                    │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR1 │ Email-only signup → welcome mail queued                                                          │ registration.hurl Step 3      │ uniform 200 + browser-binding cookie set       │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR2 │ Welcome mail contains magic-link URL                                                             │ registration.hurl Step 4      │ captured from mock SMTP                        │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR3 │ PR 22 cross-browser confirmation page                                                            │ registration.hurl Step 5a     │ 200 HTML "different browser"                   │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR4 │ Cookie-bound redemption lands on SPA                                                             │ registration.hurl Step 5b     │ 302 → /files (SvelteKit route, post-migration) │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR5 │ email_verified_at stamped after redemption                                                       │ registration.hurl Step 6      │ field present on /api/auth/me                  │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR6 │ Second magic-link post-signup                                                                    │ registration.hurl Step 7      │ uniform 200                                    │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR7 │ Profile PATCH — no-op, name set, empty-string rejected, username-taken 409, claim-once 409, etc. │ registration.hurl Steps 6a–6i │ full profile lifecycle                         │
└─────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘

Magic-link login (existing account)

┌─────┬──────────────────────────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────┐
│  #  │                           Case                           │                Where                 │                             Assertion                              │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML1 │ Baseline password login still works                      │ auth_magic_link_login.hurl Steps 1-2 │ 200                                                                │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML2 │ magic-link/send with email identifier                    │ auth_magic_link_login.hurl Step 3    │ uniform 200 + cookie                                               │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML3 │ magic-link/send with username identifier (dispatch on @) │ auth_magic_link_login.hurl Step 4    │ uniform 200                                                        │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML4 │ Password-user policy: mail actually sent                 │ auth_magic_link_login.hurl Step 5    │ SMTP capture proves permit_magic_link_for_password_users in effect │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML5 │ Redemption creates a session                             │ auth_magic_link_login.hurl Steps 6-7 │ 302 → /files, /api/auth/me returns the same user                   │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML6 │ Anti-enum on unknown identifier                          │ auth_magic_link_login.hurl Step 8    │ same uniform 200 shape as ML3                                      │
└─────┴──────────────────────────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────┘

OIDC

┌─────┬────────────────────────────────────────────────────────────────────────┬───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│  #  │                                  Case                                  │       Where       │                                                        Assertion                                                        │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O1  │ Setup local admin (bootstrap)                                          │ oidc.hurl Step 1  │ 201                                                                                                                     │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O2  │ Providers endpoint — OIDC visible                                      │ oidc.hurl Step 2  │ enabled: true, provider_name: MockSSO, password_login_enabled: true, magic_link_login_enabled: false (OIDC-master rule) │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O2b │ Magic-link/send refused (endpoint layer)                               │ oidc.hurl Step 2b │ 403 MagicLinkLoginDisabled — proves the policy gate fires, not a 503 SMTP-unwired                                       │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O3  │ Authorize redirect includes PKCE + state                               │ oidc.hurl Step 3  │ 307 to fake IdP                                                                                                         │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O4  │ IdP round-trip + JIT provisioning                                      │ oidc.hurl Step 4  │ Callback lands on /login?oidc_code=…                                                                                    │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O5  │ Code exchange → session cookies                                        │ oidc.hurl Step 5  │ 200 + all three cookies                                                                                                 │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O6  │ JIT profile mapping (name, given/family, picture, groups → admin role) │ oidc.hurl Step 6  │ every claim reflected on /api/auth/me                                                                                   │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O7  │ Refresh rotation on OIDC session                                       │ oidc.hurl Step 7  │ new access/refresh/CSRF cookies                                                                                         │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O8  │ Refreshed cookies authenticate                                         │ oidc.hurl Step 8  │ 200 on /api/auth/me                                                                                                     │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O9  │ Repeat login = same local user (no dup)                                │ oidc.hurl Step 9  │ user_id stable                                                                                                          │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O10 │ Anti-takeover: unverified email → refused                              │ oidc.hurl Step 10 │ 401/403                                                                                                                 │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O11 │ One-time code replay refused                                           │ oidc.hurl Step 11 │ second /exchange → 401                                                                                                  │
└─────┴────────────────────────────────────────────────────────────────────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘

test
This commit is contained in:
Edouard Vanbelle
2026-07-14 01:46:33 +02:00
parent 01da450cf6
commit e94063d96a
27 changed files with 1746 additions and 300 deletions
+140 -15
View File
@@ -127,21 +127,37 @@ pub async fn register(
}
};
// Block password registration when OIDC-only mode is active.
// Email-only signup still works in OIDC-only mode (no password
// stored; the user authenticates via magic-link).
// Block password registration when the policy forbids password
// logins (OIDC-only mode OR `OXICLOUD_AUTH_METHODS` allowlist
// without `password`). Email-only signup still works — the user
// authenticates via magic-link or SSO on their first visit.
if dto.password.is_some()
&& auth_service
&& !auth_service
.auth_application_service
.password_login_disabled()
.is_password_login_allowed()
{
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Password registration is disabled. Please use SSO/OIDC to sign in.",
"Password registration is disabled by policy.",
"PasswordRegistrationDisabled",
));
}
// Symmetric guard: when magic-link is off, an email-only signup has
// no path to a session (there's no token to click). Refuse rather
// than silently succeed and leave the user with an unusable account.
if dto.password.is_none()
&& !auth_service
.auth_application_service
.is_magic_link_login_allowed()
{
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Email-only registration requires magic-link login, which is disabled.",
"MagicLinkLoginDisabled",
));
}
// Admin disabled public registration globally — surface 403.
if let Some(admin_svc) = state.admin_settings_service.as_ref()
&& !admin_svc.get_registration_enabled().await
@@ -351,13 +367,19 @@ pub async fn login(
));
}
// Check if password login is disabled (OIDC-only mode)
if auth_service
// Check if password login is allowed (composes the legacy OIDC-only
// flag with the newer `OXICLOUD_AUTH_METHODS` allowlist). When
// disabled, return `PasswordLoginDisabled` so the SPA can hide the
// password field and surface the available fallback (magic-link or
// SSO) instead of showing a generic "invalid credentials".
if !auth_service
.auth_application_service
.password_login_disabled()
.is_password_login_allowed()
{
return Err(AppError::unauthorized(
"Password login is disabled. Please use SSO/OIDC to sign in.",
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Password login is disabled by policy.",
"PasswordLoginDisabled",
));
}
@@ -425,6 +447,55 @@ pub async fn login(
.login_lockout
.record_failure(&dto.username, &client_ip);
tracing::error!("Login failed for user {}: {}", dto.username, err);
// Remap the `require_verified_email` refusal (message
// string comes from AuthApplicationService::login) into a
// distinguished error_type and, critically, PIGGYBACK a
// verification link on the successful-password proof: the
// caller just showed they know the password, so we can
// safely mint a verification magic-link for their address
// without going through the anti-enum-fronted
// `magic-link/send` (which would refuse `has_password`).
//
// This branch is reached ONLY when the password validated
// successfully — the service checks `require_verified_email`
// AFTER the password check specifically so an attacker
// without the password can't discover an account's
// verification state from the response shape.
if err.message == "Email not verified" {
// Best-effort auto-send. We swallow any error and still
// return the same EmailNotVerified response — the
// frontend hint ("check your inbox") doubles as the
// resend affordance if delivery didn't land.
if let Some(invite_svc) = state.magic_link_invite_service.as_ref() {
// Re-look up the user by identifier (mirrors the
// service's login dispatch) to get the User entity
// that the verification helper needs. On any
// lookup failure we skip the send — attacker never
// sees the difference.
let lookup = if dto.username.contains('@') {
auth_service
.auth_application_service
.find_user_by_email(&dto.username)
.await
} else {
auth_service
.auth_application_service
.find_user_by_username(&dto.username)
.await
};
if let Ok(user) = lookup {
let challenge = cookie_auth::generate_magic_request_challenge();
let _ = invite_svc
.send_verification_link_authenticated(&user, &challenge)
.await;
}
}
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Your email is not verified. We sent a verification link to your inbox.",
"EmailNotVerified",
));
}
Err(err.into())
}
}
@@ -870,12 +941,22 @@ pub async fn oidc_providers(
let auth_app = &auth_service.auth_application_service;
// Policy questions the SPA needs to decide which forms to render.
// `is_magic_link_login_allowed()` composes SMTP wiring + allowlist +
// the "OIDC master → no magic-link login" hard rule; the login page
// shows the magic-link tab iff this is true.
let password_login_enabled = auth_app.is_password_login_allowed();
let magic_link_login_enabled = auth_app.is_magic_link_login_allowed();
let require_verified_email = auth_app.require_verified_email();
if !auth_app.oidc_enabled() {
return Ok(Json(OidcProviderInfoDto {
enabled: false,
provider_name: String::new(),
authorize_endpoint: String::new(),
password_login_enabled: true,
password_login_enabled,
magic_link_login_enabled,
require_verified_email,
}));
}
@@ -885,7 +966,9 @@ pub async fn oidc_providers(
enabled: true,
provider_name: config.provider_name.clone(),
authorize_endpoint: "/api/auth/oidc/authorize".to_string(),
password_login_enabled: !config.disable_password_login,
password_login_enabled,
magic_link_login_enabled,
require_verified_email,
}))
}
@@ -1127,6 +1210,21 @@ pub async fn send_magic_link(
));
};
// Policy: `OXICLOUD_AUTH_METHODS` may forbid magic-link login even
// when SMTP is wired (an operator might want the invite path — used
// by admins to seed accounts — without offering it as a login
// fallback). Refuse with the same anti-enum shape as any other
// policy-gated endpoint.
if let Some(auth) = state.auth_service.as_ref()
&& !auth.auth_application_service.is_magic_link_login_allowed()
{
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Magic-link login is disabled by policy.",
"MagicLinkLoginDisabled",
));
}
// 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
@@ -1164,6 +1262,26 @@ pub async fn send_magic_link(
)
})?;
// Login-identifier resolution. The DTO field is named `email` for
// backwards-compat, but the value may be either an email address or
// a username — dispatch matches the `POST /api/auth/login`
// convention (`@` present → email, else → username). Username
// lookups happen BEFORE rate-limiting so `alice` and
// `alice@example.com` bucket on the same key; without this,
// alternating shapes would double the effective per-email budget.
//
// Anti-enum: username misses fall through to `body.email` unchanged
// and land in the malformed_email / no_account branches downstream,
// both of which return the uniform 200 with an audit line.
let resolved_email = if let Some(auth) = state.auth_service.as_ref() {
auth.auth_application_service
.resolve_login_identifier_to_email(&body.email)
.await
.unwrap_or_else(|| body.email.clone())
} else {
body.email.clone()
};
// Per-request browser-binding challenge (PR 22). Generated for
// every request and set as a cookie on every 200 response —
// including the silent-rate-limit paths — so the cookie's
@@ -1211,8 +1329,11 @@ pub async fn send_magic_link(
// 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".
// Buckets on the RESOLVED email (post-username lookup) so
// username and email inputs for the same account share one
// budget — see resolve_login_identifier_to_email() above.
if let Ok(normalised) =
crate::domain::services::email_normalize::normalize_email(&body.email)
crate::domain::services::email_normalize::normalize_email(&resolved_email)
&& state
.magic_link_send_per_email_rate_limiter
.check_and_increment(&normalised)
@@ -1232,8 +1353,12 @@ pub async fn send_magic_link(
// 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.
// We pass the resolved email — if the caller sent a username, the
// service sees the corresponding address; if the caller sent a
// bare unknown identifier, the service still audits it as
// malformed_email / no_account.
invite_svc
.send_login_link(&body.email, &challenge)
.send_login_link(&resolved_email, &challenge)
.await
.map_err(AppError::from)?;
@@ -574,24 +574,32 @@ fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption
response
}
/// Build the SPA hash-route the redemption should land on. Mirrors the
/// front-end's `deserializeHash()` parser at `static/js/app/main.js`.
/// Build the SPA route the redemption should land on.
///
/// - **Resource token** (folder invitation): deep-link to the resource.
/// - **NULL-resource token + external user**: land on `/#/sharedwithme`
/// - **Resource token** (folder invitation): deep-link into the folder
/// view. SvelteKit `files/[...path]` accepts folder IDs as path
/// segments (see `frontend/src/routes/files/[...path]/+page.svelte`
/// — `goto(resolve(`/files/${folder.id}`))`).
/// - **NULL-resource token + external user**: land on `/shared-with-me`
/// (their entry point — they own no folders themselves).
/// - **NULL-resource token + internal user**: land on `/#/files` (the
/// - **NULL-resource token + internal user**: land on `/files` (the
/// user has a home folder; the "shared with me" view would be empty
/// on first signup, so home is the better welcome). Internal users
/// on NULL-resource tokens come from the email-only-signup welcome
/// path (PR 18) or from a magic-link they requested themselves
/// while password-eligible-and-lenient-mode (PR 19).
///
/// Historical: pre-SvelteKit these were hash routes
/// (`/#/files`, `/#/sharedwithme`, `/#/files/folder/{id}`) served by the
/// legacy vanilla frontend. Landing on those now serves the legacy
/// shell (with old meta-CSP + inline scripts) instead of the SPA and
/// triggers a CSP violation on modern deployments.
fn redirect_target(redemption: &MagicLinkRedemption) -> String {
match (redemption.resource_kind, redemption.resource_id) {
(Some(MagicLinkResourceKind::Folder), Some(folder_id)) => {
format!("/#/files/folder/{}", folder_id)
format!("/files/{}", folder_id)
}
_ if redemption.auth.user.is_external => "/#/sharedwithme".to_string(),
_ => "/#/files".to_string(),
_ if redemption.auth.user.is_external => "/shared-with-me".to_string(),
_ => "/files".to_string(),
}
}