feat(passwordless): pass3: passwordless account (via emailed magic-link)

Backend
  - RegisterDto — username and password both become Option<String> 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.
This commit is contained in:
Edouard Vanbelle
2026-06-02 22:26:11 +02:00
parent 054997d7f6
commit 9a49ab44d8
6 changed files with 252 additions and 54 deletions
+14 -2
View File
@@ -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<String>,
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<String>,
}
/// DTO for the one-time initial admin setup endpoint (`/api/setup`).
@@ -255,17 +255,20 @@ impl AuthApplicationService {
}
pub async fn register(&self, dto: RegisterDto) -> Result<UserDto, DomainError> {
// 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
+54 -24
View File
@@ -79,16 +79,20 @@ pub fn setup_route() -> Router<Arc<AppState>> {
pub async fn register(
State(state): State<Arc<AppState>>,
Json(dto): Json<RegisterDto>,
) -> Result<impl IntoResponse, AppError> {
// Add detailed logging for debugging
tracing::info!("Registration attempt for user: {}", dto.username);
) -> Result<axum::response::Response, AppError> {
// 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.
@@ -101,7 +101,7 @@ async fn redeem_magic_link(
}
fn build_success_response(state: &Arc<AppState>, 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<AppState>, 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<MagicLinkResourceKind>, id: Option<uuid::Uuid>) -> 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(),
}
}
+137
View File
@@ -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 *
+1
View File
@@ -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" \