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:
Edouard Vanbelle
2026-06-02 21:21:24 +02:00
parent 37b1c2703b
commit d57a50d056
17 changed files with 418 additions and 247 deletions
@@ -60,6 +60,33 @@ async fn create_app_password(
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
.app_password_service
.as_ref()
+5 -1
View File
@@ -878,7 +878,11 @@ pub async fn oidc_exchange(
tracing::info!(
"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
@@ -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
/// back to the username (which is always present).
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()) {
(Some(g), Some(f)) => format!("{g} {f}"),
(Some(g), None) => g.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 {
id: user.id.clone(),