feat(username|email): pass1: normalize auth.user data
username: now optional, if defined 2..64 chars
password: now optional (no mode __NO_PASSWORD...__)
oidc: now optional
important: if need Nextcloud, username must be defined
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Auth simplification — username + password_hash become nullable
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- This migration completes the auth-simplification design (PR 16 of the
|
||||||
|
-- auth-simplification plan):
|
||||||
|
--
|
||||||
|
-- * `username` becomes NULLABLE. External users get NULL; internal users
|
||||||
|
-- keep their handles. Multiple NULLs coexist under the existing UNIQUE
|
||||||
|
-- index (Postgres allows this by default).
|
||||||
|
-- * `password_hash` becomes NULLABLE. The sentinel strings
|
||||||
|
-- `__EXTERNAL_NO_PASSWORD__` and `__OIDC_NO_PASSWORD__` are NULL'd out;
|
||||||
|
-- the entity-level checks switch from string comparison to
|
||||||
|
-- `Option::is_some`.
|
||||||
|
-- * Username format CHECK tightens: 2-64 chars, no `@` (banning `@`
|
||||||
|
-- keeps the username and email namespaces provably disjoint and
|
||||||
|
-- prevents the cross-collision attack class described in the
|
||||||
|
-- auth-simplification plan).
|
||||||
|
--
|
||||||
|
-- Forward-only — do NOT squash with `20260612000003_users_username_email_login.sql`.
|
||||||
|
-- That migration has already been applied to dev / CI environments;
|
||||||
|
-- squashing would invalidate `_sqlx_migrations` checksums and lock down
|
||||||
|
-- the migration runner.
|
||||||
|
|
||||||
|
-- 1. Drop NOT NULL on the two columns we're loosening.
|
||||||
|
ALTER TABLE auth.users ALTER COLUMN username DROP NOT NULL;
|
||||||
|
ALTER TABLE auth.users ALTER COLUMN password_hash DROP NOT NULL;
|
||||||
|
|
||||||
|
-- 2. NULL out the email-shaped usernames that PR 9 stamped onto external
|
||||||
|
-- users. Their identity is the email column; the username field carried
|
||||||
|
-- a redundant duplicate that was only ever used to satisfy NOT NULL.
|
||||||
|
UPDATE auth.users
|
||||||
|
SET username = NULL
|
||||||
|
WHERE is_external = TRUE;
|
||||||
|
|
||||||
|
-- 3. NULL out the placeholder password_hash sentinels. After this migration
|
||||||
|
-- `password_hash IS NULL` means "no password set"; non-NULL means
|
||||||
|
-- "argon2 hash". No more string-comparison gymnastics in the entity.
|
||||||
|
UPDATE auth.users
|
||||||
|
SET password_hash = NULL
|
||||||
|
WHERE password_hash IN ('__EXTERNAL_NO_PASSWORD__', '__OIDC_NO_PASSWORD__');
|
||||||
|
|
||||||
|
-- 4. Tighten username format. The CHECK fires only when username IS NOT
|
||||||
|
-- NULL (existing externals stay NULL; new email-shaped values are
|
||||||
|
-- rejected at write time). Length 2-64 matches the entity validator's
|
||||||
|
-- new range. Existing internal usernames are all ≥3 and ≤32 chars,
|
||||||
|
-- so this is non-breaking for current data.
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD CONSTRAINT users_username_shape_v2
|
||||||
|
CHECK (username IS NULL
|
||||||
|
OR (username !~ '@' AND char_length(username) BETWEEN 2 AND 64));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN auth.users.username IS
|
||||||
|
'Optional handle (2-64 chars, no `@`). NULL for external users and for users who haven''t claimed one yet. UNIQUE allows multiple NULLs by default.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN auth.users.password_hash IS
|
||||||
|
'Argon2 password hash. NULL when the user has no password (externals, OIDC-only users, or post-PR-18 email-only signups awaiting their welcome magic-link).';
|
||||||
@@ -7,7 +7,13 @@ use uuid::Uuid;
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UserDto {
|
pub struct UserDto {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub username: String,
|
/// Optional handle. `None` for users who have not claimed one
|
||||||
|
/// (externals, fresh email-only signups). Frontend display callers
|
||||||
|
/// should walk `username → given/family → email` as their fallback
|
||||||
|
/// chain. Omitted from JSON when None (consistent with the existing
|
||||||
|
/// given_name / family_name fields).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub username: Option<String>,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub storage_quota_bytes: i64,
|
pub storage_quota_bytes: i64,
|
||||||
@@ -40,7 +46,7 @@ impl From<User> for UserDto {
|
|||||||
fn from(user: User) -> Self {
|
fn from(user: User) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: user.id().to_string(),
|
id: user.id().to_string(),
|
||||||
username: user.username().to_string(),
|
username: user.username().map(str::to_string),
|
||||||
email: user.email().to_string(),
|
email: user.email().to_string(),
|
||||||
role: format!("{}", user.role()),
|
role: format!("{}", user.role()),
|
||||||
storage_quota_bytes: user.storage_quota_bytes(),
|
storage_quota_bytes: user.storage_quota_bytes(),
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ impl AppPasswordService {
|
|||||||
|
|
||||||
// Fetch user for the username (needed for Basic Auth instructions)
|
// Fetch user for the username (needed for Basic Auth instructions)
|
||||||
let user = self.user_repo.get_user_by_id(user_id).await?;
|
let user = self.user_repo.get_user_by_id(user_id).await?;
|
||||||
let username = user.username().to_string();
|
let username = user.username().unwrap_or("").to_string();
|
||||||
|
|
||||||
// Generate the plain-text token
|
// Generate the plain-text token
|
||||||
let plain_token = Self::generate_token();
|
let plain_token = Self::generate_token();
|
||||||
@@ -349,7 +349,7 @@ impl AppPasswordService {
|
|||||||
|
|
||||||
let result = CachedBasicAuthResult {
|
let result = CachedBasicAuthResult {
|
||||||
user_id: user.id(),
|
user_id: user.id(),
|
||||||
username: user.username().to_string(),
|
username: user.username().unwrap_or("").to_string(),
|
||||||
email: user.email().to_string(),
|
email: user.email().to_string(),
|
||||||
role: user.role().to_string(),
|
role: user.role().to_string(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -304,15 +304,23 @@ impl AuthApplicationService {
|
|||||||
let password_hash = self.password_hasher.hash_password(&dto.password).await?;
|
let password_hash = self.password_hasher.hash_password(&dto.password).await?;
|
||||||
|
|
||||||
// Create user with the pre-generated hash
|
// Create user with the pre-generated hash
|
||||||
let user = User::new(dto.username.clone(), dto.email, password_hash, role, quota).map_err(
|
let user = User::new(
|
||||||
|e| {
|
dto.email,
|
||||||
|
Some(dto.username.clone()),
|
||||||
|
Some(password_hash),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
role,
|
||||||
|
quota,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
DomainError::new(
|
DomainError::new(
|
||||||
ErrorKind::InvalidInput,
|
ErrorKind::InvalidInput,
|
||||||
"User",
|
"User",
|
||||||
format!("Error creating user: {}", e),
|
format!("Error creating user: {}", e),
|
||||||
)
|
)
|
||||||
},
|
})?;
|
||||||
)?;
|
|
||||||
|
|
||||||
// Save user
|
// Save user
|
||||||
let created_user = self.user_storage.create_user(user).await?;
|
let created_user = self.user_storage.create_user(user).await?;
|
||||||
@@ -387,7 +395,17 @@ impl AuthApplicationService {
|
|||||||
let quota = self.capped_quota(&role);
|
let quota = self.capped_quota(&role);
|
||||||
let password_hash = self.password_hasher.hash_password(&password).await?;
|
let password_hash = self.password_hasher.hash_password(&password).await?;
|
||||||
|
|
||||||
let user = User::new(username.clone(), email, password_hash, role, quota).map_err(|e| {
|
let user = User::new(
|
||||||
|
email,
|
||||||
|
Some(username.clone()),
|
||||||
|
Some(password_hash),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
role,
|
||||||
|
quota,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
DomainError::new(
|
DomainError::new(
|
||||||
ErrorKind::InvalidInput,
|
ErrorKind::InvalidInput,
|
||||||
"User",
|
"User",
|
||||||
@@ -442,9 +460,9 @@ impl AuthApplicationService {
|
|||||||
event = "auth.login_rejected",
|
event = "auth.login_rejected",
|
||||||
reason = "account_deactivated",
|
reason = "account_deactivated",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
"🔐 login rejected: account deactivated for '{}'",
|
"🔐 login rejected: account deactivated for '{}'",
|
||||||
user.username(),
|
user.display_for_audit(),
|
||||||
);
|
);
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::AccessDenied,
|
ErrorKind::AccessDenied,
|
||||||
@@ -453,10 +471,29 @@ impl AuthApplicationService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password using the injected hasher
|
// Verify password using the injected hasher. If the user has no
|
||||||
|
// password configured (externals, OIDC-only), short-circuit to
|
||||||
|
// "invalid credentials" — the password-login path never accepts
|
||||||
|
// a NULL hash.
|
||||||
|
let Some(hash) = user.password_hash() else {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.login_rejected",
|
||||||
|
reason = "no_password",
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.display_for_audit(),
|
||||||
|
"🔐 login rejected: user has no password configured for '{}'",
|
||||||
|
user.display_for_audit(),
|
||||||
|
);
|
||||||
|
return Err(DomainError::new(
|
||||||
|
ErrorKind::AccessDenied,
|
||||||
|
"Auth",
|
||||||
|
"Invalid credentials",
|
||||||
|
));
|
||||||
|
};
|
||||||
let is_valid = self
|
let is_valid = self
|
||||||
.password_hasher
|
.password_hasher
|
||||||
.verify_password(&dto.password, user.password_hash())
|
.verify_password(&dto.password, hash)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !is_valid {
|
if !is_valid {
|
||||||
@@ -465,9 +502,9 @@ impl AuthApplicationService {
|
|||||||
event = "auth.login_rejected",
|
event = "auth.login_rejected",
|
||||||
reason = "bad_password",
|
reason = "bad_password",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
"🔐 login rejected: bad password for '{}'",
|
"🔐 login rejected: bad password for '{}'",
|
||||||
user.username(),
|
user.display_for_audit(),
|
||||||
);
|
);
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::AccessDenied,
|
ErrorKind::AccessDenied,
|
||||||
@@ -627,9 +664,9 @@ impl AuthApplicationService {
|
|||||||
reason = "account_deactivated",
|
reason = "account_deactivated",
|
||||||
token_id = %mlt.id(),
|
token_id = %mlt.id(),
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
"🔗 magic-link rejected: account deactivated for '{}'",
|
"🔗 magic-link rejected: account deactivated for '{}'",
|
||||||
user.username(),
|
user.display_for_audit(),
|
||||||
);
|
);
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::AccessDenied,
|
ErrorKind::AccessDenied,
|
||||||
@@ -662,7 +699,7 @@ impl AuthApplicationService {
|
|||||||
target: "audit",
|
target: "audit",
|
||||||
event = "magic_link.redeemed",
|
event = "magic_link.redeemed",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
is_external = user.is_external(),
|
is_external = user.is_external(),
|
||||||
resource_kind = ?mlt.resource_kind(),
|
resource_kind = ?mlt.resource_kind(),
|
||||||
resource_id = ?mlt.resource_id(),
|
resource_id = ?mlt.resource_id(),
|
||||||
@@ -705,10 +742,14 @@ impl AuthApplicationService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_valid = self
|
let Some(hash) = user.password_hash() else {
|
||||||
.password_hasher
|
return Err(DomainError::new(
|
||||||
.verify_password(password, user.password_hash())
|
ErrorKind::AccessDenied,
|
||||||
.await?;
|
"Auth",
|
||||||
|
"Invalid credentials",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let is_valid = self.password_hasher.verify_password(password, hash).await?;
|
||||||
|
|
||||||
if !is_valid {
|
if !is_valid {
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
@@ -720,7 +761,7 @@ impl AuthApplicationService {
|
|||||||
|
|
||||||
Ok(crate::application::dtos::user_dto::CurrentUser {
|
Ok(crate::application::dtos::user_dto::CurrentUser {
|
||||||
id: user.id(),
|
id: user.id(),
|
||||||
username: user.username().to_string(),
|
username: user.username().unwrap_or("").to_string(),
|
||||||
email: user.email().to_string(),
|
email: user.email().to_string(),
|
||||||
role: user.role().to_string(),
|
role: user.role().to_string(),
|
||||||
})
|
})
|
||||||
@@ -874,9 +915,16 @@ impl AuthApplicationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify current password using the injected hasher
|
// Verify current password using the injected hasher
|
||||||
|
let Some(hash) = user.password_hash() else {
|
||||||
|
return Err(DomainError::new(
|
||||||
|
ErrorKind::AccessDenied,
|
||||||
|
"Auth",
|
||||||
|
"Current password is incorrect",
|
||||||
|
));
|
||||||
|
};
|
||||||
let is_valid = self
|
let is_valid = self
|
||||||
.password_hasher
|
.password_hasher
|
||||||
.verify_password(&dto.current_password, user.password_hash())
|
.verify_password(&dto.current_password, hash)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !is_valid {
|
if !is_valid {
|
||||||
@@ -901,7 +949,7 @@ impl AuthApplicationService {
|
|||||||
.password_hasher
|
.password_hasher
|
||||||
.hash_password(&dto.new_password)
|
.hash_password(&dto.new_password)
|
||||||
.await?;
|
.await?;
|
||||||
user.update_password_hash(new_hash);
|
user.update_password_hash(Some(new_hash));
|
||||||
|
|
||||||
// Save updated user
|
// Save updated user
|
||||||
self.user_storage.update_user(user.clone()).await?;
|
self.user_storage.update_user(user.clone()).await?;
|
||||||
@@ -1263,7 +1311,7 @@ impl AuthApplicationService {
|
|||||||
|
|
||||||
// External users never own storage. The DB `users_external_no_storage`
|
// External users never own storage. The DB `users_external_no_storage`
|
||||||
// CHECK constraint enforces this; setting quota=0 here keeps the
|
// CHECK constraint enforces this; setting quota=0 here keeps the
|
||||||
// domain consistent and matches `User::new_external`.
|
// domain consistent and matches `User::new(..., is_external = true)`.
|
||||||
let quota = if is_external {
|
let quota = if is_external {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
@@ -1275,19 +1323,33 @@ impl AuthApplicationService {
|
|||||||
// magic-link / OIDC, but the DB column is NOT NULL).
|
// magic-link / OIDC, but the DB column is NOT NULL).
|
||||||
let password_hash = self.password_hasher.hash_password(&dto.password).await?;
|
let password_hash = self.password_hasher.hash_password(&dto.password).await?;
|
||||||
|
|
||||||
// Create domain entity. External path uses `new_external` so the
|
// Create domain entity. External users are created with
|
||||||
// is_external flag is set + the EXTERNAL placeholder password
|
// is_external=true and role forced to User (the admin+external
|
||||||
// marker is applied for clarity in DB inspection. `new_external`
|
// combo was rejected above). For external users the supplied
|
||||||
// forces role=User (the admin+external combo was rejected above).
|
// password hash is persisted so the audit trail is preserved,
|
||||||
|
// even though they authenticate via magic-link / OIDC.
|
||||||
let user = if is_external {
|
let user = if is_external {
|
||||||
User::new_external(dto.username.clone(), email).map(|mut u| {
|
User::new(
|
||||||
// The hashed password from the request is unused for auth
|
email,
|
||||||
// but is persisted so audit-trail integrity is preserved.
|
Some(dto.username.clone()),
|
||||||
u.update_password_hash(password_hash);
|
Some(password_hash),
|
||||||
u
|
None,
|
||||||
})
|
None,
|
||||||
|
UserRole::User,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
User::new(dto.username.clone(), email, password_hash, role, quota)
|
User::new(
|
||||||
|
email,
|
||||||
|
Some(dto.username.clone()),
|
||||||
|
Some(password_hash),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
role,
|
||||||
|
quota,
|
||||||
|
false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::new(
|
DomainError::new(
|
||||||
@@ -1375,7 +1437,11 @@ impl AuthApplicationService {
|
|||||||
/// `Err`, the transaction rolls back and the user remains intact.
|
/// `Err`, the transaction rolls back and the user remains intact.
|
||||||
pub async fn delete_user_admin(&self, user_id: Uuid) -> Result<(), DomainError> {
|
pub async fn delete_user_admin(&self, user_id: Uuid) -> Result<(), DomainError> {
|
||||||
let user = self.user_storage.get_user_by_id(user_id).await?;
|
let user = self.user_storage.get_user_by_id(user_id).await?;
|
||||||
tracing::info!("Admin deleting user: {} ({})", user.username(), user_id);
|
tracing::info!(
|
||||||
|
"Admin deleting user: {} ({})",
|
||||||
|
user.display_for_audit(),
|
||||||
|
user_id
|
||||||
|
);
|
||||||
|
|
||||||
let mut tx = self
|
let mut tx = self
|
||||||
.user_storage
|
.user_storage
|
||||||
@@ -1783,13 +1849,15 @@ impl AuthApplicationService {
|
|||||||
username = format!("{}_{}", &username[..username.len().min(27)], suffix);
|
username = format!("{}_{}", &username[..username.len().min(27)], suffix);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut new_user = User::new_oidc(
|
let mut new_user = User::new(
|
||||||
username.clone(),
|
|
||||||
oidc_email,
|
oidc_email,
|
||||||
|
Some(username.clone()),
|
||||||
|
None,
|
||||||
|
Some(provider_name.clone()),
|
||||||
|
Some(claims.sub.clone()),
|
||||||
role,
|
role,
|
||||||
quota,
|
quota,
|
||||||
provider_name.clone(),
|
false,
|
||||||
claims.sub.clone(),
|
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::new(
|
DomainError::new(
|
||||||
@@ -1829,13 +1897,13 @@ impl AuthApplicationService {
|
|||||||
// Nextcloud path: return user info so the handler can mint an
|
// Nextcloud path: return user info so the handler can mint an
|
||||||
// app-password and complete the NC login flow.
|
// app-password and complete the NC login flow.
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
user = %user.username(),
|
user = %user.display_for_audit(),
|
||||||
"OIDC login successful for Nextcloud Login Flow v2"
|
"OIDC login successful for Nextcloud Login Flow v2"
|
||||||
);
|
);
|
||||||
return Ok(OidcCallbackResult::NextcloudLogin {
|
return Ok(OidcCallbackResult::NextcloudLogin {
|
||||||
nc_flow_token: nc_token,
|
nc_flow_token: nc_token,
|
||||||
user_id: user.id(),
|
user_id: user.id(),
|
||||||
username: user.username().to_string(),
|
username: user.username().unwrap_or("").to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ impl UserLifecycleHook for ExternalIdentityLifecycleHook {
|
|||||||
target: "audit",
|
target: "audit",
|
||||||
event = "external_user.created",
|
event = "external_user.created",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
email = %user.email(),
|
email = %user.email(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ impl UserLifecycleHook for ExternalIdentityLifecycleHook {
|
|||||||
target: "audit",
|
target: "audit",
|
||||||
event = "external_user.login",
|
event = "external_user.login",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
first_login = user.last_login_at().is_none(),
|
first_login = user.last_login_at().is_none(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -621,7 +621,7 @@ impl FolderService {
|
|||||||
pub async fn ensure_home_folder(
|
pub async fn ensure_home_folder(
|
||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
username: &str,
|
username: Option<&str>,
|
||||||
) -> Result<bool, DomainError> {
|
) -> Result<bool, DomainError> {
|
||||||
let existing = self
|
let existing = self
|
||||||
.folder_storage
|
.folder_storage
|
||||||
@@ -637,7 +637,10 @@ impl FolderService {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let folder_name = format!("My Folder - {}", username);
|
let folder_name = match username {
|
||||||
|
Some(u) => format!("My Folder - {}", u),
|
||||||
|
None => format!("My Folder - {}", user_id),
|
||||||
|
};
|
||||||
self.folder_storage
|
self.folder_storage
|
||||||
.create_home_folder(user_id, folder_name.clone())
|
.create_home_folder(user_id, folder_name.clone())
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
|||||||
use crate::common::config::MagicLinkConfig;
|
use crate::common::config::MagicLinkConfig;
|
||||||
use crate::common::errors::{DomainError, ErrorKind};
|
use crate::common::errors::{DomainError, ErrorKind};
|
||||||
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkToken};
|
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkToken};
|
||||||
use crate::domain::entities::user::User;
|
use crate::domain::entities::user::{User, UserRole};
|
||||||
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||||
use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError};
|
use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError};
|
||||||
use crate::domain::services::authorization::{Resource, ResourceKind};
|
use crate::domain::services::authorization::{Resource, ResourceKind};
|
||||||
@@ -119,10 +119,18 @@ impl MagicLinkInviteService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// username == normalised email for external users. The user
|
// External users are created without a username or password.
|
||||||
// entity's `validate_username` was widened to 254 chars + email
|
// `password_hash IS NULL` is the canonical no-password marker.
|
||||||
// shape in PR 6 specifically to allow this.
|
let user = User::new(
|
||||||
let user = User::new_external(normalised_email.to_string(), normalised_email.to_string())
|
normalised_email.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
UserRole::User,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::new(
|
DomainError::new(
|
||||||
ErrorKind::InvalidInput,
|
ErrorKind::InvalidInput,
|
||||||
@@ -308,10 +316,10 @@ impl MagicLinkInviteService {
|
|||||||
event = "auth.magic_link_send",
|
event = "auth.magic_link_send",
|
||||||
reason = "has_credential",
|
reason = "has_credential",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
email = %normalised,
|
email = %normalised,
|
||||||
"🔗 login-link suppressed: '{}' has another login credential",
|
"🔗 login-link suppressed: '{}' has another login credential",
|
||||||
user.username(),
|
user.display_for_audit(),
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -322,10 +330,10 @@ impl MagicLinkInviteService {
|
|||||||
event = "auth.magic_link_send",
|
event = "auth.magic_link_send",
|
||||||
reason = "account_deactivated",
|
reason = "account_deactivated",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
email = %normalised,
|
email = %normalised,
|
||||||
"🔗 login-link suppressed: account deactivated for '{}'",
|
"🔗 login-link suppressed: account deactivated for '{}'",
|
||||||
user.username(),
|
user.display_for_audit(),
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -372,7 +380,7 @@ impl MagicLinkInviteService {
|
|||||||
event = "auth.magic_link_send",
|
event = "auth.magic_link_send",
|
||||||
reason = "sent",
|
reason = "sent",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
email = %normalised,
|
email = %normalised,
|
||||||
smtp_code = outcome.code,
|
smtp_code = outcome.code,
|
||||||
smtp_message = %outcome.message,
|
smtp_message = %outcome.message,
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ impl SubjectGroupService {
|
|||||||
// recipients; placing one in a subject group would let any later
|
// recipients; placing one in a subject group would let any later
|
||||||
// group-grant on an internal resource silently leak access.
|
// group-grant on an internal resource silently leak access.
|
||||||
// Mirrors the no-external-admins enforcement style in
|
// Mirrors the no-external-admins enforcement style in
|
||||||
// `User::new_external`.
|
// `User::new(..., is_external = true)`.
|
||||||
if let GroupMember::User(uid) = member {
|
if let GroupMember::User(uid) = member {
|
||||||
match UserRepository::get_user_by_id(&*self.user_storage, uid).await {
|
match UserRepository::get_user_by_id(&*self.user_storage, uid).await {
|
||||||
Ok(user) if user.is_external() => {
|
Ok(user) if user.is_external() => {
|
||||||
@@ -588,12 +588,11 @@ mod integration_tests {
|
|||||||
id, username, email, password_hash, role,
|
id, username, email, password_hash, role,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, active, is_external
|
created_at, updated_at, active, is_external
|
||||||
) VALUES ($1, $2, $3, '__EXTERNAL_NO_PASSWORD__', 'user'::auth.userrole,
|
) VALUES ($1, NULL, $2, NULL, 'user'::auth.userrole,
|
||||||
0, 0, NOW(), NOW(), TRUE, TRUE)",
|
0, 0, NOW(), NOW(), TRUE, TRUE)",
|
||||||
)
|
)
|
||||||
.bind(external_id)
|
.bind(external_id)
|
||||||
.bind(format!("ext-{}@example.com", &external_id.to_string()[..8]))
|
.bind(format!("ext-{}@example.com", &external_id.to_string()[..8]))
|
||||||
.bind(format!("ext-{}@example.com", &external_id.to_string()[..8]))
|
|
||||||
.execute(svc.pool.as_ref())
|
.execute(svc.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.expect("seed external user");
|
.expect("seed external user");
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
|||||||
target: "audit",
|
target: "audit",
|
||||||
event = "user.created",
|
event = "user.created",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
is_external = user.is_external(),
|
is_external = user.is_external(),
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -162,7 +162,7 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
|||||||
target: "audit",
|
target: "audit",
|
||||||
event = "user.login",
|
event = "user.login",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
is_external = user.is_external(),
|
is_external = user.is_external(),
|
||||||
first_login = user.last_login_at().is_none(),
|
first_login = user.last_login_at().is_none(),
|
||||||
);
|
);
|
||||||
@@ -174,7 +174,7 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
|||||||
target: "audit",
|
target: "audit",
|
||||||
event = "user.logout",
|
event = "user.logout",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
is_external = user.is_external(),
|
is_external = user.is_external(),
|
||||||
reason = ?reason,
|
reason = ?reason,
|
||||||
);
|
);
|
||||||
@@ -193,7 +193,7 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
|||||||
target: "audit",
|
target: "audit",
|
||||||
event = "user.deleted",
|
event = "user.deleted",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
is_external = user.is_external(),
|
is_external = user.is_external(),
|
||||||
mode = ?mode,
|
mode = ?mode,
|
||||||
);
|
);
|
||||||
@@ -272,7 +272,7 @@ impl UserLifecycleHook for SessionRevocationLifecycleHook {
|
|||||||
target: "audit",
|
target: "audit",
|
||||||
event = "user.sessions_revoked_on_delete",
|
event = "user.sessions_revoked_on_delete",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
username = %user.username(),
|
username = %user.display_for_audit(),
|
||||||
mode = ?mode,
|
mode = ?mode,
|
||||||
count = count,
|
count = count,
|
||||||
);
|
);
|
||||||
|
|||||||
+138
-154
@@ -23,9 +23,19 @@ impl std::fmt::Display for UserRole {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
username: String,
|
/// Optional handle (2-64 chars, no `@`). NULL for users created via
|
||||||
|
/// email-invitation (`is_external = true`) and for users who have
|
||||||
|
/// not yet claimed a handle (PR-18 email-only signups). When set, it
|
||||||
|
/// must satisfy `validate_username` and must NOT contain `@` —
|
||||||
|
/// keeping the username and email namespaces provably disjoint.
|
||||||
|
username: Option<String>,
|
||||||
email: String,
|
email: String,
|
||||||
password_hash: String,
|
/// Optional Argon2 password hash. NULL when the user has no password
|
||||||
|
/// (externals, OIDC-only users, email-only signups awaiting their
|
||||||
|
/// welcome magic-link). After PR 16 this column carries no sentinel
|
||||||
|
/// strings — `is_some()` means "real argon2 hash"; `None` means "no
|
||||||
|
/// password configured".
|
||||||
|
password_hash: Option<String>,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
storage_quota_bytes: i64,
|
storage_quota_bytes: i64,
|
||||||
storage_used_bytes: i64,
|
storage_used_bytes: i64,
|
||||||
@@ -54,37 +64,70 @@ pub struct User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
/// Create a new user with a pre-hashed password.
|
/// Create a new user.
|
||||||
///
|
///
|
||||||
/// The password hashing should be done externally using PasswordHasherPort
|
/// One unified constructor for every kind of user (internal, OIDC-linked,
|
||||||
/// to maintain clean architecture and keep cryptographic dependencies
|
/// external). The credential slots and the `is_external` marker are all
|
||||||
/// out of the domain layer.
|
/// caller-controlled — what makes a user "OIDC" is `oidc_subject =
|
||||||
|
/// Some(_)`, what makes them "external" is `is_external = true`. There
|
||||||
|
/// are no hidden sentinel values; an absent credential is `None`.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `username` - User's username (3-254 characters; may be an email)
|
/// * `email` — required, must satisfy `validate_email`
|
||||||
/// * `email` - User's email address
|
/// * `username` — optional handle (2-64 chars, no `@`)
|
||||||
/// * `password_hash` - Pre-hashed password (from PasswordHasherPort)
|
/// * `password_hash` — pre-hashed via PasswordHasherPort, or `None` if
|
||||||
/// * `role` - User's role
|
/// the user has no password yet (magic-link or OIDC bootstrap)
|
||||||
/// * `storage_quota_bytes` - Storage quota in bytes
|
/// * `oidc_provider`, `oidc_subject` — both `Some` when the user is
|
||||||
|
/// linked to an external IdP, both `None` otherwise
|
||||||
|
/// * `role` — `Admin` is rejected when `is_external = true` (mirrors the
|
||||||
|
/// `users_external_not_admin` DB CHECK constraint)
|
||||||
|
/// * `storage_quota_bytes` — caller-set; external callers should pass 0
|
||||||
|
/// to satisfy the `users_external_no_storage` invariant
|
||||||
|
/// * `is_external` — TRUE for grant-only recipients (magic-link, OCM)
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
username: String,
|
|
||||||
email: String,
|
email: String,
|
||||||
password_hash: String,
|
username: Option<String>,
|
||||||
|
password_hash: Option<String>,
|
||||||
|
oidc_provider: Option<String>,
|
||||||
|
oidc_subject: Option<String>,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
storage_quota_bytes: i64,
|
storage_quota_bytes: i64,
|
||||||
|
is_external: bool,
|
||||||
) -> UserResult<Self> {
|
) -> UserResult<Self> {
|
||||||
// Validations
|
|
||||||
Self::validate_username(&username)?;
|
|
||||||
Self::validate_email(&email)?;
|
Self::validate_email(&email)?;
|
||||||
|
if let Some(ref u) = username {
|
||||||
if password_hash.is_empty() {
|
Self::validate_username(u)?;
|
||||||
|
}
|
||||||
|
if let Some(ref h) = password_hash
|
||||||
|
&& h.is_empty()
|
||||||
|
{
|
||||||
return Err(UserError::InvalidPassword(
|
return Err(UserError::InvalidPassword(
|
||||||
"Password hash cannot be empty".to_string(),
|
"Password hash cannot be empty".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// Schema-level CHECKs are mirrored at the entity layer so callers
|
||||||
|
// get a typed error instead of an opaque DB rejection.
|
||||||
|
if is_external && matches!(role, UserRole::Admin) {
|
||||||
|
return Err(UserError::ValidationError(
|
||||||
|
"External users cannot hold the admin role".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if is_external && storage_quota_bytes != 0 {
|
||||||
|
return Err(UserError::ValidationError(
|
||||||
|
"External users must have storage_quota_bytes = 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// OIDC linkage is all-or-nothing: both provider and subject set,
|
||||||
|
// or neither. The DB has a UNIQUE index on (provider, subject)
|
||||||
|
// WHERE both non-NULL; partial state would corrupt that.
|
||||||
|
if oidc_provider.is_some() != oidc_subject.is_some() {
|
||||||
|
return Err(UserError::ValidationError(
|
||||||
|
"oidc_provider and oidc_subject must both be set or both be None".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
username,
|
username,
|
||||||
@@ -97,86 +140,10 @@ impl User {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
last_login_at: None,
|
last_login_at: None,
|
||||||
active: true,
|
active: true,
|
||||||
oidc_provider: None,
|
oidc_provider,
|
||||||
oidc_subject: None,
|
oidc_subject,
|
||||||
image: None,
|
image: None,
|
||||||
is_external: false,
|
is_external,
|
||||||
given_name: None,
|
|
||||||
family_name: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new OIDC-authenticated user (no password required).
|
|
||||||
pub fn new_oidc(
|
|
||||||
username: String,
|
|
||||||
email: String,
|
|
||||||
role: UserRole,
|
|
||||||
storage_quota_bytes: i64,
|
|
||||||
oidc_provider: String,
|
|
||||||
oidc_subject: String,
|
|
||||||
) -> UserResult<Self> {
|
|
||||||
Self::validate_username(&username)?;
|
|
||||||
Self::validate_email(&email)?;
|
|
||||||
let now = Utc::now();
|
|
||||||
Ok(Self {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
password_hash: "__OIDC_NO_PASSWORD__".to_string(),
|
|
||||||
role,
|
|
||||||
storage_quota_bytes,
|
|
||||||
storage_used_bytes: 0,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
last_login_at: None,
|
|
||||||
active: true,
|
|
||||||
oidc_provider: Some(oidc_provider),
|
|
||||||
oidc_subject: Some(oidc_subject),
|
|
||||||
image: None,
|
|
||||||
is_external: false,
|
|
||||||
given_name: None,
|
|
||||||
family_name: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new external user — magic-link / OIDC-only / OCM-federated
|
|
||||||
/// recipient who does NOT own storage. The `CHECK (NOT is_external OR
|
|
||||||
/// storage_used_bytes = 0)` DB constraint enforces the no-storage rule
|
|
||||||
/// at the schema level.
|
|
||||||
///
|
|
||||||
/// **External users are always `UserRole::User`** — there is no role
|
|
||||||
/// parameter because admin + external is an explicitly forbidden
|
|
||||||
/// combination enforced by the `users_external_not_admin` DB CHECK
|
|
||||||
/// constraint. Granting admin to a federated principal would let
|
|
||||||
/// external identity providers indirectly manage the local instance.
|
|
||||||
/// To make an external user an admin: first convert them to internal
|
|
||||||
/// (`UPDATE auth.users SET is_external = FALSE`), then update role.
|
|
||||||
/// The two-step process is intentional friction.
|
|
||||||
///
|
|
||||||
/// Quota is set to 0 because external users can't upload content
|
|
||||||
/// into any folder they own (they have no folder). They can only
|
|
||||||
/// act on grants the resource owner provides — which counts against
|
|
||||||
/// the owner's quota, not theirs.
|
|
||||||
pub fn new_external(username: String, email: String) -> UserResult<Self> {
|
|
||||||
Self::validate_username(&username)?;
|
|
||||||
Self::validate_email(&email)?;
|
|
||||||
let now = Utc::now();
|
|
||||||
Ok(Self {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
password_hash: "__EXTERNAL_NO_PASSWORD__".to_string(),
|
|
||||||
role: UserRole::User,
|
|
||||||
storage_quota_bytes: 0,
|
|
||||||
storage_used_bytes: 0,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
last_login_at: None,
|
|
||||||
active: true,
|
|
||||||
oidc_provider: None,
|
|
||||||
oidc_subject: None,
|
|
||||||
image: None,
|
|
||||||
is_external: true,
|
|
||||||
given_name: None,
|
given_name: None,
|
||||||
family_name: None,
|
family_name: None,
|
||||||
})
|
})
|
||||||
@@ -185,9 +152,9 @@ impl User {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn from_data(
|
pub fn from_data(
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
username: String,
|
username: Option<String>,
|
||||||
email: String,
|
email: String,
|
||||||
password_hash: String,
|
password_hash: Option<String>,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
storage_quota_bytes: i64,
|
storage_quota_bytes: i64,
|
||||||
storage_used_bytes: i64,
|
storage_used_bytes: i64,
|
||||||
@@ -226,9 +193,9 @@ impl User {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn from_data_full(
|
pub fn from_data_full(
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
username: String,
|
username: Option<String>,
|
||||||
email: String,
|
email: String,
|
||||||
password_hash: String,
|
password_hash: Option<String>,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
storage_quota_bytes: i64,
|
storage_quota_bytes: i64,
|
||||||
storage_used_bytes: i64,
|
storage_used_bytes: i64,
|
||||||
@@ -269,8 +236,12 @@ impl User {
|
|||||||
self.id
|
self.id
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn username(&self) -> &str {
|
/// The user's chosen handle. `None` for users who have not claimed
|
||||||
&self.username
|
/// one (externals, fresh email-only signups). Display callers should
|
||||||
|
/// fall back through `given_name`/`family_name` to `email` when this
|
||||||
|
/// is `None`.
|
||||||
|
pub fn username(&self) -> Option<&str> {
|
||||||
|
self.username.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn email(&self) -> &str {
|
pub fn email(&self) -> &str {
|
||||||
@@ -305,8 +276,31 @@ impl User {
|
|||||||
self.active
|
self.active
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn password_hash(&self) -> &str {
|
/// The Argon2 password hash, or `None` when the user has no password
|
||||||
&self.password_hash
|
/// configured (externals, OIDC-only users, post-PR-18 email-only
|
||||||
|
/// signups). `verify_password` callers must short-circuit to
|
||||||
|
/// "invalid credentials" when this is `None`.
|
||||||
|
pub fn password_hash(&self) -> Option<&str> {
|
||||||
|
self.password_hash.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: does the user have a real password configured?
|
||||||
|
pub fn has_password(&self) -> bool {
|
||||||
|
self.password_hash.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort label for audit-log interpolation. Returns the
|
||||||
|
/// username when set; falls back to the user_id otherwise. Always
|
||||||
|
/// implements `Display` (returns `String`) so audit lines can stay
|
||||||
|
/// `username = %user.display_for_audit()` regardless of whether the
|
||||||
|
/// user has claimed a handle. Reserve this for `target: "audit"`
|
||||||
|
/// lines — user-facing display callers should walk the
|
||||||
|
/// `username → given/family → email` fallback chain themselves.
|
||||||
|
pub fn display_for_audit(&self) -> String {
|
||||||
|
match &self.username {
|
||||||
|
Some(u) => u.clone(),
|
||||||
|
None => self.id.to_string(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn oidc_provider(&self) -> Option<&str> {
|
pub fn oidc_provider(&self) -> Option<&str> {
|
||||||
@@ -352,44 +346,48 @@ impl User {
|
|||||||
self.updated_at = Utc::now();
|
self.updated_at = Utc::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mutate the username after creation. Runs the same validation as the
|
/// Claim or change the username. Runs the same validation as the
|
||||||
/// constructor — callers must still ensure uniqueness at the repo
|
/// constructor — callers must still ensure uniqueness at the repo
|
||||||
/// level. Bumps `updated_at`. Used by the post-create profile-edit
|
/// level. Bumps `updated_at`. Used by the post-create profile-edit
|
||||||
/// endpoint so a user invited with `username = email` can switch to a
|
/// endpoint so a user who started with `None` can claim a handle
|
||||||
/// shorter handle later. The home folder name is NOT renamed: it was
|
/// later, or change to a different one. The home folder name is NOT
|
||||||
/// display text at creation; the folder is owned by `user_id`.
|
/// renamed: it was display text at creation; the folder is owned
|
||||||
|
/// by `user_id`.
|
||||||
pub fn set_username(&mut self, new_username: String) -> UserResult<()> {
|
pub fn set_username(&mut self, new_username: String) -> UserResult<()> {
|
||||||
Self::validate_username(&new_username)?;
|
Self::validate_username(&new_username)?;
|
||||||
self.username = new_username;
|
self.username = Some(new_username);
|
||||||
self.updated_at = Utc::now();
|
self.updated_at = Utc::now();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unset the username (return to `None`). Use sparingly — most
|
||||||
|
/// users keep their handle once claimed. Mainly here so admin
|
||||||
|
/// tooling can clear a problematic handle without deleting the
|
||||||
|
/// account.
|
||||||
|
pub fn clear_username(&mut self) {
|
||||||
|
self.username = None;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true if this is an OIDC-only user (no password)
|
/// Returns true if this is an OIDC-only user (no password)
|
||||||
pub fn is_oidc_user(&self) -> bool {
|
pub fn is_oidc_user(&self) -> bool {
|
||||||
self.oidc_provider.is_some()
|
self.oidc_provider.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true iff this user has any non-magic-link authentication
|
/// Returns true iff this user has any non-magic-link authentication
|
||||||
/// method available — either a real (non-placeholder) password hash,
|
/// method available — either a real password hash, or a linked OIDC
|
||||||
/// or a linked OIDC subject. Magic-link auto-authentication is only
|
/// subject. Magic-link eligibility for "no other credential" mode is
|
||||||
/// offered for accounts without any of these.
|
/// the negation of this; the `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS`
|
||||||
///
|
/// flag widens the policy at the service layer (`magic_link_eligibility`).
|
||||||
/// The placeholder-string approach is a known smell; a future
|
|
||||||
/// `auth.user_auth_methods` side-table will replace it. Migrating that
|
|
||||||
/// refactor touches only this method's body — every magic-link
|
|
||||||
/// eligibility check goes through here.
|
|
||||||
pub fn has_login_credential(&self) -> bool {
|
pub fn has_login_credential(&self) -> bool {
|
||||||
let has_password = self.password_hash != "__EXTERNAL_NO_PASSWORD__"
|
self.password_hash.is_some() || self.oidc_subject.is_some()
|
||||||
&& self.password_hash != "__OIDC_NO_PASSWORD__";
|
|
||||||
has_password || self.oidc_subject.is_some()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the password hash.
|
/// Set the password hash. The new password must be hashed externally
|
||||||
///
|
/// via `PasswordHasherPort` before calling this. Passing `None`
|
||||||
/// The new password should be hashed externally using PasswordHasherPort
|
/// clears the password (e.g. when a user opts back into magic-link-only
|
||||||
/// before calling this method.
|
/// auth).
|
||||||
pub fn update_password_hash(&mut self, new_hash: String) {
|
pub fn update_password_hash(&mut self, new_hash: Option<String>) {
|
||||||
self.password_hash = new_hash;
|
self.password_hash = new_hash;
|
||||||
self.updated_at = Utc::now();
|
self.updated_at = Utc::now();
|
||||||
}
|
}
|
||||||
@@ -421,39 +419,26 @@ impl User {
|
|||||||
|
|
||||||
// ── Shared validation helpers ──────────────────────────────────────
|
// ── Shared validation helpers ──────────────────────────────────────
|
||||||
|
|
||||||
/// Usernames must be 3-254 chars. Two accepted shapes:
|
/// Usernames are 2-64 chars of `[A-Za-z0-9._-]`. The `@` character is
|
||||||
///
|
/// explicitly forbidden — keeping the username and email namespaces
|
||||||
/// - **Traditional**: ASCII alphanumerics, hyphens, underscores, and
|
/// provably disjoint is what closes the cross-collision attack class
|
||||||
/// dots. No leading/trailing dot or hyphen. Capped at 254 chars
|
/// described in the auth-simplification plan (a user can never claim
|
||||||
/// (well above the historical 32-char limit, but still safe — the
|
/// a handle that shadows another user's email). No leading/trailing
|
||||||
/// real upper bound is RFC 5321's email cap for the email shape).
|
/// dot or hyphen. The character set also prevents XSS payloads from
|
||||||
/// - **Email-as-username**: must contain `@` and pass `validate_email`.
|
/// being stored as usernames.
|
||||||
/// External users created from invite-by-email get their normalized
|
|
||||||
/// email as username; internal users may opt into this if they
|
|
||||||
/// prefer their email as their handle.
|
|
||||||
///
|
|
||||||
/// Both shapes prevent XSS payloads like `<img/src=x>` from being
|
|
||||||
/// stored as usernames — the traditional shape via the explicit
|
|
||||||
/// character set, the email shape via `validate_email`'s rejection of
|
|
||||||
/// `<`, `>`, quotes, whitespace, etc.
|
|
||||||
fn validate_username(username: &str) -> UserResult<()> {
|
fn validate_username(username: &str) -> UserResult<()> {
|
||||||
if username.len() < 3 || username.len() > 254 {
|
let len = username.chars().count();
|
||||||
|
if !(2..=64).contains(&len) {
|
||||||
return Err(UserError::InvalidUsername(
|
return Err(UserError::InvalidUsername(
|
||||||
"Username must be between 3 and 254 characters".to_string(),
|
"Username must be between 2 and 64 characters".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if username.contains('@') {
|
if username.contains('@') {
|
||||||
// Email shape — defer to the email validator (which checks the
|
return Err(UserError::InvalidUsername(
|
||||||
// forbidden-character set and the local-part / domain structure).
|
"Username must not contain '@' — use the email field for email addresses"
|
||||||
return Self::validate_email(username).map_err(|e| match e {
|
.to_string(),
|
||||||
UserError::ValidationError(m) => {
|
));
|
||||||
UserError::InvalidUsername(format!("Invalid email-as-username: {}", m))
|
|
||||||
}
|
}
|
||||||
other => other,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if !username
|
if !username
|
||||||
.chars()
|
.chars()
|
||||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||||
@@ -463,7 +448,6 @@ impl User {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Disallow leading/trailing dots or hyphens
|
|
||||||
if username.starts_with('.')
|
if username.starts_with('.')
|
||||||
|| username.starts_with('-')
|
|| username.starts_with('-')
|
||||||
|| username.ends_with('.')
|
|| username.ends_with('.')
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ impl TokenServicePort for JwtTokenService {
|
|||||||
// Log information for debugging
|
// Log information for debugging
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Generating token for user: {}, id: {}, role: {}",
|
"Generating token for user: {}, id: {}, role: {}",
|
||||||
user.username(),
|
user.display_for_audit(),
|
||||||
user.id(),
|
user.id(),
|
||||||
user.role()
|
user.role()
|
||||||
);
|
);
|
||||||
@@ -166,7 +166,7 @@ impl TokenServicePort for JwtTokenService {
|
|||||||
exp: now + self.access_token_expiry,
|
exp: now + self.access_token_expiry,
|
||||||
iat: now,
|
iat: now,
|
||||||
jti: Uuid::new_v4().to_string(),
|
jti: Uuid::new_v4().to_string(),
|
||||||
username: user.username().to_string(),
|
username: user.username().unwrap_or("").to_string(),
|
||||||
email: user.email().to_string(),
|
email: user.email().to_string(),
|
||||||
role: format!("{}", user.role()),
|
role: format!("{}", user.role()),
|
||||||
};
|
};
|
||||||
@@ -267,9 +267,9 @@ mod tests {
|
|||||||
fn create_test_user() -> User {
|
fn create_test_user() -> User {
|
||||||
User::from_data(
|
User::from_data(
|
||||||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||||
"testuser".to_string(),
|
Some("testuser".to_string()),
|
||||||
"test@example.com".to_string(),
|
"test@example.com".to_string(),
|
||||||
"hashed_password".to_string(),
|
Some("hashed_password".to_string()),
|
||||||
UserRole::User,
|
UserRole::User,
|
||||||
1024 * 1024 * 1024, // 1GB
|
1024 * 1024 * 1024, // 1GB
|
||||||
0,
|
0,
|
||||||
@@ -297,7 +297,7 @@ mod tests {
|
|||||||
.validate_token(&token)
|
.validate_token(&token)
|
||||||
.expect("Should validate token");
|
.expect("Should validate token");
|
||||||
assert_eq!(claims.sub, user.id().to_string());
|
assert_eq!(claims.sub, user.id().to_string());
|
||||||
assert_eq!(claims.username, user.username());
|
assert_eq!(Some(claims.username.as_str()), user.username());
|
||||||
assert_eq!(claims.email, user.email());
|
assert_eq!(claims.email, user.email());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -959,7 +959,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
)
|
)
|
||||||
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
|
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
|
||||||
ag.subject_type, ag.subject_id,
|
ag.subject_type, ag.subject_id,
|
||||||
COALESCE(u.username, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
COALESCE(u.username, u.email, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||||
rp.sort_str, rp.sort_int,
|
rp.sort_str, rp.sort_int,
|
||||||
(sh.password_hash IS NOT NULL) AS has_password
|
(sh.password_hash IS NOT NULL) AS has_password
|
||||||
@@ -983,7 +983,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
WHEN ag.subject_type = 'token' AND sh.password_hash IS NOT NULL THEN 2
|
WHEN ag.subject_type = 'token' AND sh.password_hash IS NOT NULL THEN 2
|
||||||
ELSE 3
|
ELSE 3
|
||||||
END ASC,
|
END ASC,
|
||||||
LOWER(COALESCE(u.username, sg.name::text, sh.item_name, ag.subject_id::text)) ASC,
|
LOWER(COALESCE(u.username, u.email, sg.name::text, sh.item_name, ag.subject_id::text)) ASC,
|
||||||
ag.granted_at"#
|
ag.granted_at"#
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1023,7 +1023,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
ag.resource_id,
|
ag.resource_id,
|
||||||
ag.subject_type,
|
ag.subject_type,
|
||||||
ag.subject_id,
|
ag.subject_id,
|
||||||
MAX(COALESCE(u.username, sg.name::text, sh.item_name, ag.subject_id::text)) AS subject_display,
|
MAX(COALESCE(u.username, u.email, sg.name::text, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||||
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
||||||
MAX(CASE
|
MAX(CASE
|
||||||
WHEN ag.subject_type = 'group' THEN 0
|
WHEN ag.subject_type = 'group' THEN 0
|
||||||
@@ -1108,7 +1108,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
ag.resource_id,
|
ag.resource_id,
|
||||||
ag.subject_type,
|
ag.subject_type,
|
||||||
ag.subject_id,
|
ag.subject_id,
|
||||||
MAX(COALESCE(u.username, sh.item_name, ag.subject_id::text)) AS subject_display,
|
MAX(COALESCE(u.username, u.email, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||||
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
||||||
CASE
|
CASE
|
||||||
WHEN BOOL_OR(ag.permission = 'delete')
|
WHEN BOOL_OR(ag.permission = 'delete')
|
||||||
@@ -1196,7 +1196,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
)
|
)
|
||||||
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
|
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
|
||||||
ag.subject_type, ag.subject_id,
|
ag.subject_type, ag.subject_id,
|
||||||
COALESCE(u.username, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
COALESCE(u.username, u.email, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||||
NULL::text AS sort_str, NULL::bigint AS sort_int,
|
NULL::text AS sort_str, NULL::bigint AS sort_int,
|
||||||
(sh.password_hash IS NOT NULL) AS has_password
|
(sh.password_hash IS NOT NULL) AS has_password
|
||||||
|
|||||||
@@ -60,6 +60,33 @@ async fn create_app_password(
|
|||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require a claimed username. NextCloud Basic Auth resolves users by
|
||||||
|
// username; an app password is unusable without one. UserDto carries
|
||||||
|
// an empty string when the underlying `users.username` is NULL — the
|
||||||
|
// entity rejects empty strings on construction, so empty here is an
|
||||||
|
// unambiguous signal that the column is NULL.
|
||||||
|
if let Some(auth_svc) = state.auth_service.as_ref() {
|
||||||
|
let user_dto = auth_svc
|
||||||
|
.auth_application_service
|
||||||
|
.get_user_by_id(user.id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
if user_dto.username.is_none() {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.app_password_create_rejected",
|
||||||
|
reason = "no_username",
|
||||||
|
caller_id = %user.id,
|
||||||
|
"App-password creation requires a claimed username"
|
||||||
|
);
|
||||||
|
return Err(AppError::new(
|
||||||
|
axum::http::StatusCode::CONFLICT,
|
||||||
|
"Claim a username on your profile before creating an app password.",
|
||||||
|
"UsernameRequired",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let service = state
|
let service = state
|
||||||
.app_password_service
|
.app_password_service
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -878,7 +878,11 @@ pub async fn oidc_exchange(
|
|||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"OIDC token exchange successful for user: {}",
|
"OIDC token exchange successful for user: {}",
|
||||||
auth_response.user.username
|
auth_response
|
||||||
|
.user
|
||||||
|
.username
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&auth_response.user.email)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set HttpOnly cookies for the browser
|
// Set HttpOnly cookies for the browser
|
||||||
|
|||||||
@@ -188,11 +188,13 @@ fn if_match_passes(if_match: Option<&str>, stored_etag: &str) -> bool {
|
|||||||
/// they're present, prefer a "First Last" full name; otherwise fall
|
/// they're present, prefer a "First Last" full name; otherwise fall
|
||||||
/// back to the username (which is always present).
|
/// back to the username (which is always present).
|
||||||
fn user_to_contact(user: UserDto) -> ContactDto {
|
fn user_to_contact(user: UserDto) -> ContactDto {
|
||||||
|
// Display fallback chain: given+family name → username → email.
|
||||||
|
// Username is `Option<String>` post PR 16; externals start with None.
|
||||||
let full_name = match (user.given_name.as_deref(), user.family_name.as_deref()) {
|
let full_name = match (user.given_name.as_deref(), user.family_name.as_deref()) {
|
||||||
(Some(g), Some(f)) => format!("{g} {f}"),
|
(Some(g), Some(f)) => format!("{g} {f}"),
|
||||||
(Some(g), None) => g.to_string(),
|
(Some(g), None) => g.to_string(),
|
||||||
(None, Some(f)) => f.to_string(),
|
(None, Some(f)) => f.to_string(),
|
||||||
(None, None) => user.username.clone(),
|
(None, None) => user.username.clone().unwrap_or_else(|| user.email.clone()),
|
||||||
};
|
};
|
||||||
ContactDto {
|
ContactDto {
|
||||||
id: user.id.clone(),
|
id: user.id.clone(),
|
||||||
|
|||||||
@@ -271,19 +271,26 @@ pub async fn handle_sharees_search(
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Skip users with no claimed username — NC sharees autocomplete relies
|
||||||
|
// on a username being typeable; users still on the email-only signup
|
||||||
|
// path can't be addressed here. Also skip self (don't suggest sharing
|
||||||
|
// with yourself).
|
||||||
let matches: Vec<serde_json::Value> = users
|
let matches: Vec<serde_json::Value> = users
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|u| u.username != user.username) // Don't suggest self
|
.filter_map(|u| {
|
||||||
.take(25)
|
let handle = u.username.clone()?;
|
||||||
.map(|u| {
|
if handle == user.username {
|
||||||
json!({
|
return None;
|
||||||
"label": u.username,
|
}
|
||||||
|
Some(json!({
|
||||||
|
"label": handle,
|
||||||
"value": {
|
"value": {
|
||||||
"shareType": 0,
|
"shareType": 0,
|
||||||
"shareWith": u.username
|
"shareWith": handle,
|
||||||
}
|
}
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
})
|
.take(25)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
sharees_response(matches).into_response()
|
sharees_response(matches).into_response()
|
||||||
|
|||||||
@@ -212,7 +212,9 @@ HTTP 403
|
|||||||
|
|
||||||
# 11c — /api/users/{id}: bob CAN look up his own profile (self-lookup
|
# 11c — /api/users/{id}: bob CAN look up his own profile (self-lookup
|
||||||
# is the first allow rule) so the SharedWithMe view can show
|
# is the first allow rule) so the SharedWithMe view can show
|
||||||
# his own avatar in the user menu.
|
# his own avatar in the user menu. After PR 16 externals have
|
||||||
|
# NULL username (the field is omitted from JSON when None) —
|
||||||
|
# the email field is the identity.
|
||||||
GET {{base_url}}/api/users/{{bob_user_id}}
|
GET {{base_url}}/api/users/{{bob_user_id}}
|
||||||
Authorization: Bearer {{bob_access_token}}
|
Authorization: Bearer {{bob_access_token}}
|
||||||
|
|
||||||
@@ -220,6 +222,8 @@ HTTP 200
|
|||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$.id" == "{{bob_user_id}}"
|
jsonpath "$.id" == "{{bob_user_id}}"
|
||||||
jsonpath "$.is_external" == true
|
jsonpath "$.is_external" == true
|
||||||
|
jsonpath "$.email" == "bob@externalcompany.com"
|
||||||
|
jsonpath "$.username" not exists
|
||||||
|
|
||||||
# 11d — bob CAN look up Alice (his granter) — shared-grant relationship
|
# 11d — bob CAN look up Alice (his granter) — shared-grant relationship
|
||||||
# lets the external recipient resolve the sharer's display name +
|
# lets the external recipient resolve the sharer's display name +
|
||||||
@@ -285,7 +289,8 @@ HTTP 403
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 12 — /api/users/{id} happy path (Alice → Bob).
|
# Step 12 — /api/users/{id} happy path (Alice → Bob).
|
||||||
# Visibility rule: they share a grant, so Alice sees
|
# Visibility rule: they share a grant, so Alice sees
|
||||||
# Bob's profile (with is_external=true).
|
# Bob's profile (with is_external=true). Bob's username
|
||||||
|
# is NULL post PR 16 (externals don't carry a handle).
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
GET {{base_url}}/api/users/{{bob_user_id}}
|
GET {{base_url}}/api/users/{{bob_user_id}}
|
||||||
Authorization: Bearer {{alice_token}}
|
Authorization: Bearer {{alice_token}}
|
||||||
@@ -294,6 +299,8 @@ HTTP 200
|
|||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$.id" == "{{bob_user_id}}"
|
jsonpath "$.id" == "{{bob_user_id}}"
|
||||||
jsonpath "$.is_external" == true
|
jsonpath "$.is_external" == true
|
||||||
|
jsonpath "$.email" == "bob@externalcompany.com"
|
||||||
|
jsonpath "$.username" not exists
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user