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