From 9a49ab44d8552692cfa2e57f69332a50ab473997 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 2 Jun 2026 22:26:11 +0200 Subject: [PATCH] feat(passwordless): pass3: passwordless account (via emailed magic-link) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - RegisterDto — username and password both become Option with #[serde(default)] so JSON can omit them entirely. - AuthApplicationService::register — username uniqueness check skipped when None (multiple NULLs OK under the UNIQUE index); password hashing skipped when None; User::new called with the actual Options instead of forcing Some(...). - auth_handler::register — branches on dto.password.is_none(). With password → existing 201 + UserDto. Without → triggers MagicLinkInviteService::send_login_link(&email) best-effort, then returns 200 + {"message": "Check your email…"}. The OIDC-mode-disables-password-registration gate now only fires for the password path (email-only signup is still allowed even in OIDC-only mode, because it doesn't store a password). - magic_link_handler::redirect_target — new 3-way decision tree: - Resource target (folder invitation) → /#/files/folder/{id} (existing) - NULL resource + is_external = false → /#/files (the welcome path for new internal users — they have a home folder) - NULL resource + is_external = true → /#/sharedwithme (the existing external-user landing) Tests - New tests/api/registration.hurl with 9 requests covering: classic (with-password) register → 201 + UserDto, email-only register → 200 + uniform message + welcome magic-link captured, redemption → 302 to /#/files + cookies set, profile read → username absent + is_external: false, resend magic-link works (eligible while passwordless), cleanup deletes both new users. - Wired into tests/api/run.sh right after auth_login.hurl. Plan additions - auth-simplification.md gained PR 22 at the bottom of the PR sequence — device-bound magic-link redemption via challenge cookie + asymmetric TTLs (login: 10 min, invitation: 24 h). Full design recap, schema migration, config knobs (OXICLOUD_MAGIC_LINK_LOGIN_TTL_MINUTES / _INVITE_TTL_HOURS), and Hurl coverage outline are in the plan. Slots in before PR 21's docs so the architecture page describes the final state from the start. Checks — cargo fmt, cargo clippy --all-features --all-targets -- -D warnings, cargo test --lib (297 passed), biome, stylelint, tsc, full Hurl suite (16 files) all green. --- src/application/dtos/user_dto.rs | 16 +- .../services/auth_application_service.rs | 55 ++++--- src/interfaces/api/handlers/auth_handler.rs | 78 +++++++--- .../api/handlers/magic_link_handler.rs | 19 ++- tests/api/registration.hurl | 137 ++++++++++++++++++ tests/api/run.sh | 1 + 6 files changed, 252 insertions(+), 54 deletions(-) create mode 100644 tests/api/registration.hurl diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 4fa79ff6..fd90861a 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -80,9 +80,21 @@ pub struct LoginDto { #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct RegisterDto { - pub username: String, + /// Optional handle (2-64 chars, no `@`). When omitted, the user can + /// claim one later via the profile-edit endpoint. Users without a + /// username cannot use NextCloud clients or create app passwords + /// (Basic-Auth resolves users by username); web UI / native API + /// works fine without one. + #[serde(default)] + pub username: Option, pub email: String, - pub password: String, + /// Optional password (≥8 chars when present). When omitted, a + /// welcome magic-link is mailed to `email` for first-session + /// bootstrap. The user can later set a password via the + /// change-password endpoint to switch to classic username/email + + /// password login. + #[serde(default)] + pub password: Option, } /// DTO for the one-time initial admin setup endpoint (`/api/setup`). diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index b93c1e2f..e3c4ea68 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -255,17 +255,20 @@ impl AuthApplicationService { } pub async fn register(&self, dto: RegisterDto) -> Result { - // Check for duplicate user - if self - .user_storage - .get_user_by_username(&dto.username) - .await - .is_ok() + // Username uniqueness (only when a username was supplied — None + // is the "claim later" path, multiple NULLs are allowed by the + // UNIQUE index per Postgres semantics). + if let Some(ref username) = dto.username + && self + .user_storage + .get_user_by_username(username) + .await + .is_ok() { return Err(DomainError::new( ErrorKind::AlreadyExists, "User", - format!("User '{}' already exists", dto.username), + format!("User '{}' already exists", username), )); } @@ -287,27 +290,30 @@ impl AuthApplicationService { // 1. The one-time /api/setup endpoint (first boot) // 2. The admin panel (admin_create_user) let role = UserRole::User; - - // Quota based on role, capped to available disk space let quota = self.capped_quota(&role); - // Validate password length before hashing - if dto.password.len() < 8 { - return Err(DomainError::new( - ErrorKind::InvalidInput, - "User", - "Password must be at least 8 characters long", - )); - } + // Validate password length before hashing — only when one is + // supplied. Omitted password means the user opts into the + // magic-link bootstrap path. + let password_hash = match dto.password { + Some(ref pw) => { + if pw.len() < 8 { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "User", + "Password must be at least 8 characters long", + )); + } + Some(self.password_hasher.hash_password(pw).await?) + } + None => None, + }; + let was_passwordless = password_hash.is_none(); - // Hash the password using the infrastructure service - let password_hash = self.password_hasher.hash_password(&dto.password).await?; - - // Create user with the pre-generated hash let user = User::new( - dto.email, - Some(dto.username.clone()), - Some(password_hash), + dto.email.clone(), + dto.username.clone(), + password_hash, None, None, role, @@ -324,6 +330,7 @@ impl AuthApplicationService { // Save user let created_user = self.user_storage.create_user(user).await?; + let _ = was_passwordless; // handler dispatches the welcome mail on this path // Lifecycle: HomeFolderLifecycleHook handles personal-folder // creation (was inlined here pre-PR 3); audit log + future diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 3cf5a4d4..8ad0b4c9 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -79,16 +79,20 @@ pub fn setup_route() -> Router> { pub async fn register( State(state): State>, Json(dto): Json, -) -> Result { - // Add detailed logging for debugging - tracing::info!("Registration attempt for user: {}", dto.username); +) -> Result { + // Display the supplied identifier in operational logs without + // panicking on the None branch — the user may have registered + // email-only with no username yet. + let log_identifier = dto + .username + .as_deref() + .unwrap_or(dto.email.as_str()) + .to_string(); + tracing::info!("Registration attempt for: {}", log_identifier); // Verify auth service exists let auth_service = match state.auth_service.as_ref() { - Some(service) => { - tracing::info!("Auth service found, proceeding with registration"); - service - } + Some(service) => service, None => { tracing::error!("Auth service not configured"); return Err(AppError::internal_error( @@ -97,10 +101,13 @@ pub async fn register( } }; - // Fix #5: Block password registration when OIDC-only mode is active - if auth_service - .auth_application_service - .password_login_disabled() + // Block password registration when OIDC-only mode is active. The + // email-only signup path is allowed because it doesn't store a + // password — the user later authenticates via magic-link. + if dto.password.is_some() + && auth_service + .auth_application_service + .password_login_disabled() { return Err(AppError::new( StatusCode::FORBIDDEN, @@ -120,22 +127,45 @@ pub async fn register( )); } - // Registration logic (admin detection, fresh-install handling, duplicate - // checks) is all inside the service layer. Call it directly. - match auth_service - .auth_application_service - .register(dto.clone()) - .await - { - Ok(user) => { - tracing::info!("Registration successful for user: {}", dto.username); - Ok((StatusCode::CREATED, Json(user))) - } + let was_passwordless = dto.password.is_none(); + let email = dto.email.clone(); + + // Registration logic (duplicate checks, hashing, user creation) is + // all inside the service layer. + let user = match auth_service.auth_application_service.register(dto).await { + Ok(u) => u, Err(err) => { - tracing::error!("Registration failed for user {}: {}", dto.username, err); - Err(err.into()) + tracing::error!("Registration failed for {}: {}", log_identifier, err); + return Err(err.into()); } + }; + tracing::info!("Registration successful for: {}", log_identifier); + + // Email-only signup: dispatch a welcome magic-link so the user can + // land their first session without a password. Best-effort — SMTP + // failures don't fail the registration. Response shape is uniform + // (200 + anti-enumeration message) so the user is told to check + // their email regardless of whether SMTP was actually wired. + if was_passwordless { + if let Some(invite) = state.magic_link_invite_service.as_ref() + && let Err(e) = invite.send_login_link(&email).await + { + tracing::warn!( + target: "audit", + event = "auth.register_welcome_mail_failed", + user_id = %user.id, + email = %email, + error = %e, + "register: welcome magic-link send failed (user created)", + ); + } + let payload = serde_json::json!({ + "message": "Check your email for a sign-in link to complete registration.", + }); + return Ok((StatusCode::OK, Json(payload)).into_response()); } + + Ok((StatusCode::CREATED, Json(user)).into_response()) } /// Authenticate with username and password. diff --git a/src/interfaces/api/handlers/magic_link_handler.rs b/src/interfaces/api/handlers/magic_link_handler.rs index eb3aaa5c..84d69166 100644 --- a/src/interfaces/api/handlers/magic_link_handler.rs +++ b/src/interfaces/api/handlers/magic_link_handler.rs @@ -101,7 +101,7 @@ async fn redeem_magic_link( } fn build_success_response(state: &Arc, redemption: MagicLinkRedemption) -> Response { - let target = redirect_target(redemption.resource_kind, redemption.resource_id); + let target = redirect_target(&redemption); let mut response = (StatusCode::FOUND, [(LOCATION, target.as_str())]).into_response(); @@ -119,12 +119,23 @@ fn build_success_response(state: &Arc, redemption: MagicLinkRedemption /// Build the SPA hash-route the redemption should land on. Mirrors the /// front-end's `deserializeHash()` parser at `static/js/app/main.js`. -fn redirect_target(kind: Option, id: Option) -> String { - match (kind, id) { +/// +/// - **Resource token** (folder invitation): deep-link to the resource. +/// - **NULL-resource token + external user**: land on `/#/sharedwithme` +/// (their entry point — they own no folders themselves). +/// - **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). +fn redirect_target(redemption: &MagicLinkRedemption) -> String { + match (redemption.resource_kind, redemption.resource_id) { (Some(MagicLinkResourceKind::Folder), Some(folder_id)) => { format!("/#/files/folder/{}", folder_id) } - _ => "/#/sharedwithme".to_string(), + _ if redemption.auth.user.is_external => "/#/sharedwithme".to_string(), + _ => "/#/files".to_string(), } } diff --git a/tests/api/registration.hurl b/tests/api/registration.hurl new file mode 100644 index 00000000..e560c5e0 --- /dev/null +++ b/tests/api/registration.hurl @@ -0,0 +1,137 @@ +# ============================================================= +# OxiCloud — email-only registration (PR 18) +# ============================================================= +# PR 18 makes `password` (and `username`) optional in +# `POST /api/auth/register`. Email-only signup: +# - returns a uniform 200 message (no JWT, no UserDto) +# - mints a welcome magic-link mailed to `email` +# - redemption lands the new internal user on `/#/files` +# (not `/#/sharedwithme`, which is for externals) +# +# Requires `OXICLOUD_SMTP_MOCK=true` (set in tests/common/server.env). +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — admin login (cleanup ops at the end need her token). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +alice_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Classic registration (with password) still works. +# Returns 201 + UserDto (existing behaviour, unchanged). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/register +Content-Type: application/json +{ + "username": "charlie", + "email": "charlie@example.com", + "password": "TestPassword1!" +} + +HTTP 201 +[Asserts] +jsonpath "$.username" == "charlie" +jsonpath "$.email" == "charlie@example.com" +jsonpath "$.is_external" == false +[Captures] +charlie_user_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Email-only registration. No username, no password. +# Returns 200 + uniform message; welcome magic-link +# is captured by the MockEmailSender. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/register +Content-Type: application/json +{ + "email": "pr18-emailonly@example.com" +} + +HTTP 200 +[Asserts] +jsonpath "$.message" contains "sign-in link" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Capture the welcome mail + extract the magic-link. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/admin/smtp/test/captured?to=pr18-emailonly@example.com +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$.to" == "pr18-emailonly@example.com" +jsonpath "$.text_body" matches "/magic/v1/[A-Za-z0-9_-]+" +[Captures] +pr18_magic_url: jsonpath "$.text_body" regex "(https?://[^\\s]+/magic/v1/[A-Za-z0-9_-]+)" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Redeem the welcome link. Internal user with no +# resource target → lands on `/#/files` (NOT +# `/#/sharedwithme`, which is the external-user +# landing). +# ───────────────────────────────────────────────────────────── +GET {{pr18_magic_url}} + +HTTP 302 +[Asserts] +header "Location" == "/#/files" +[Captures] +pr18_access_token: cookie "oxicloud_access" + + +# ───────────────────────────────────────────────────────────── +# Step 6 — The new user can read their own profile. After PR 18 +# the username field is omitted (no handle claimed yet), +# and `is_external` is false (they're an internal user +# who signed up directly, not via invitation). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/me +Authorization: Bearer {{pr18_access_token}} + +HTTP 200 +[Asserts] +jsonpath "$.email" == "pr18-emailonly@example.com" +jsonpath "$.is_external" == false +jsonpath "$.username" not exists +[Captures] +pr18_user_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Dave can request another magic-link (he has no +# password configured → eligible). Anti-enumeration +# 200 either way. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/magic-link/send +Content-Type: application/json +{ "email": "pr18-emailonly@example.com" } + +HTTP 200 +[Asserts] +jsonpath "$.message" contains "sign-in link" + + +# ───────────────────────────────────────────────────────────── +# Cleanup — admin deletes charlie + dave so the DB-clean sweep +# at run.sh end sees no stragglers. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/admin/users/{{charlie_user_id}} +Authorization: Bearer {{alice_token}} + +HTTP * + +DELETE {{base_url}}/api/admin/users/{{pr18_user_id}} +Authorization: Bearer {{alice_token}} + +HTTP * diff --git a/tests/api/run.sh b/tests/api/run.sh index 78b07dec..5bc8adff 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -91,6 +91,7 @@ log "Running Hurl tests..." hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test --jobs 1 \ "$API_DIR/setup.hurl" \ "$API_DIR/auth_login.hurl" \ + "$API_DIR/registration.hurl" \ "$API_DIR/files-folders.hurl" \ "$API_DIR/favorites.hurl" \ "$API_DIR/trash.hurl" \