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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user