feat(username|email): pass2: accept login via email orusername

- login via (username or email) + password
    - hurl test to cover the feature
This commit is contained in:
Edouard Vanbelle
2026-06-02 21:46:01 +02:00
parent d57a50d056
commit ac24a0eda1
21 changed files with 155 additions and 27 deletions
+7
View File
@@ -67,6 +67,13 @@ impl From<User> for UserDto {
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct LoginDto {
/// Identifier the user typed. Accepts BOTH a username (no `@`) and
/// an email address (`@` present). The server dispatches on
/// `@`-in-input: with `@` it looks up by email; without, by
/// username. The two namespaces are provably disjoint (PR 16
/// forbids `@` in usernames), so a single field handles both
/// without ambiguity. The frontend submits whatever the user
/// typed in the "Username or email" field as-is.
pub username: String,
pub password: String,
}
@@ -432,26 +432,30 @@ impl AuthApplicationService {
}
pub async fn login(&self, dto: LoginDto) -> Result<AuthResponseDto, DomainError> {
// Find user
let mut user = self
.user_storage
.get_user_by_username(&dto.username)
.await
.map_err(|_| {
// Audit: unknown-username login attempt. Reason key kept
// stable so log search can aggregate without parsing the
// human-readable message. Caller's client IP + request id
// are attached automatically by the request-scope span.
tracing::info!(
target: "audit",
event = "auth.login_rejected",
reason = "unknown_user",
attempted_username = %dto.username,
"🔐 login rejected: no such user '{}'",
dto.username,
);
DomainError::new(ErrorKind::AccessDenied, "Auth", "Invalid credentials")
})?;
// Dispatch on `@` in the input: presence of `@` means an email
// was typed, absence means a username. The two namespaces are
// provably disjoint (PR 16 forbids `@` in usernames), so this
// is unambiguous — one DB lookup, no fallback chain.
let lookup = if dto.username.contains('@') {
self.user_storage.get_user_by_email(&dto.username).await
} else {
self.user_storage.get_user_by_username(&dto.username).await
};
let mut user = lookup.map_err(|_| {
// Audit: unknown-identifier login attempt. Reason key kept
// stable so log search can aggregate without parsing the
// human-readable message. Caller's client IP + request id
// are attached automatically by the request-scope span.
tracing::info!(
target: "audit",
event = "auth.login_rejected",
reason = "unknown_user",
attempted_username = %dto.username,
"🔐 login rejected: no such user '{}'",
dto.username,
);
DomainError::new(ErrorKind::AccessDenied, "Auth", "Invalid credentials")
})?;
// Check if user is active
if !user.is_active() {
+2
View File
@@ -381,6 +381,8 @@
"login_title": "تسجيل الدخول",
"username": "اسم المستخدم",
"username_placeholder": "أدخل اسم المستخدم",
"login_identifier": "اسم المستخدم أو البريد الإلكتروني",
"login_identifier_placeholder": "أدخل اسم المستخدم أو البريد الإلكتروني",
"password": "كلمة المرور",
"password_placeholder": "أدخل كلمة المرور",
"login_button": "تسجيل الدخول",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "Anmelden",
"username": "Benutzername",
"username_placeholder": "Geben Sie Ihren Benutzernamen ein",
"login_identifier": "Benutzername oder E-Mail",
"login_identifier_placeholder": "Geben Sie Ihren Benutzernamen oder Ihre E-Mail-Adresse ein",
"password": "Passwort",
"password_placeholder": "Geben Sie Ihr Passwort ein",
"login_button": "Anmelden",
+2
View File
@@ -382,6 +382,8 @@
"login_title": "Sign in",
"username": "Username",
"username_placeholder": "Enter your username",
"login_identifier": "Username or email",
"login_identifier_placeholder": "Enter your username or email",
"password": "Password",
"password_placeholder": "Enter your password",
"login_button": "Sign in",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "Iniciar sesión",
"username": "Usuario",
"username_placeholder": "Ingresa tu nombre de usuario",
"login_identifier": "Usuario o correo electrónico",
"login_identifier_placeholder": "Ingresa tu usuario o correo electrónico",
"password": "Contraseña",
"password_placeholder": "Ingresa tu contraseña",
"login_button": "Iniciar sesión",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "ورود",
"username": "نام‌کاربری",
"username_placeholder": "نام‌کاربری خود را وارد کنید",
"login_identifier": "نام کاربری یا ایمیل",
"login_identifier_placeholder": "نام کاربری یا ایمیل خود را وارد کنید",
"password": "گذرواژه",
"password_placeholder": "گذرواژه خود را وارد کنید",
"login_button": "ورود",
+2
View File
@@ -382,6 +382,8 @@
"login_title": "Se connecter",
"username": "Nom d'utilisateur",
"username_placeholder": "Entrez votre nom d'utilisateur",
"login_identifier": "Nom d'utilisateur ou e-mail",
"login_identifier_placeholder": "Saisissez votre nom d'utilisateur ou e-mail",
"password": "Mot de passe",
"password_placeholder": "Entrez votre mot de passe",
"login_button": "Se connecter",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "साइन इन",
"username": "उपयोगकर्ता नाम",
"username_placeholder": "अपना उपयोगकर्ता नाम दर्ज करें",
"login_identifier": "उपयोगकर्ता नाम या ईमेल",
"login_identifier_placeholder": "अपना उपयोगकर्ता नाम या ईमेल दर्ज करें",
"password": "पासवर्ड",
"password_placeholder": "अपना पासवर्ड दर्ज करें",
"login_button": "साइन इन",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "Accedi",
"username": "Nome utente",
"username_placeholder": "Inserisci il tuo nome utente",
"login_identifier": "Nome utente o email",
"login_identifier_placeholder": "Inserisci il tuo nome utente o email",
"password": "Password",
"password_placeholder": "Inserisci la tua password",
"login_button": "Accedi",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "サインイン",
"username": "ユーザー名",
"username_placeholder": "ユーザー名を入力",
"login_identifier": "ユーザー名またはメールアドレス",
"login_identifier_placeholder": "ユーザー名またはメールアドレスを入力",
"password": "パスワード",
"password_placeholder": "パスワードを入力",
"login_button": "サインイン",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "로그인",
"username": "사용자 이름",
"username_placeholder": "사용자 이름을 입력하세요",
"login_identifier": "사용자 이름 또는 이메일",
"login_identifier_placeholder": "사용자 이름 또는 이메일을 입력하세요",
"password": "비밀번호",
"password_placeholder": "비밀번호를 입력하세요",
"login_button": "로그인",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "Inloggen",
"username": "Gebruikersnaam",
"username_placeholder": "Voer je gebruikersnaam in",
"login_identifier": "Gebruikersnaam of e-mail",
"login_identifier_placeholder": "Voer uw gebruikersnaam of e-mailadres in",
"password": "Wachtwoord",
"password_placeholder": "Voer je wachtwoord in",
"login_button": "Inloggen",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "Zaloguj się",
"username": "Nazwa użytkownika",
"username_placeholder": "Wprowadź nazwę użytkownika",
"login_identifier": "Nazwa użytkownika lub e-mail",
"login_identifier_placeholder": "Wpisz nazwę użytkownika lub e-mail",
"password": "Hasło",
"password_placeholder": "Wprowadź hasło",
"login_button": "Zaloguj się",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "Entrar",
"username": "Usuário",
"username_placeholder": "Digite seu nome de usuário",
"login_identifier": "Usuário ou e-mail",
"login_identifier_placeholder": "Digite seu usuário ou e-mail",
"password": "Senha",
"password_placeholder": "Digite sua senha",
"login_button": "Entrar",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "Вход",
"username": "Имя пользователя",
"username_placeholder": "Введите имя пользователя",
"login_identifier": "Имя пользователя или email",
"login_identifier_placeholder": "Введите имя пользователя или email",
"password": "Пароль",
"password_placeholder": "Введите пароль",
"login_button": "Войти",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "登入",
"username": "使用者名稱",
"username_placeholder": "輸入你的使用者名稱",
"login_identifier": "使用者名稱或電子郵件",
"login_identifier_placeholder": "請輸入使用者名稱或電子郵件",
"password": "密碼",
"password_placeholder": "輸入你的密碼",
"login_button": "登入",
+2
View File
@@ -381,6 +381,8 @@
"login_title": "登录",
"username": "用户名",
"username_placeholder": "输入你的用户名",
"login_identifier": "用户名或邮箱",
"login_identifier_placeholder": "请输入用户名或邮箱",
"password": "密码",
"password_placeholder": "输入你的密码",
"login_button": "登录",
+7 -7
View File
@@ -72,13 +72,13 @@
<form class="auth-form" id="login-form">
<div class="auth-input-group">
<label class="auth-label" for="login-username" data-i18n="auth.username">Username</label>
<input
type="text"
id="login-username"
class="auth-input"
data-i18n-placeholder="auth.username_placeholder"
placeholder="Enter your username"
<label class="auth-label" for="login-username" data-i18n="auth.login_identifier">Username or email</label>
<input
type="text"
id="login-username"
class="auth-input"
data-i18n-placeholder="auth.login_identifier_placeholder"
placeholder="Enter your username or email"
required
>
</div>
+84
View File
@@ -0,0 +1,84 @@
# =============================================================
# OxiCloud — login dispatcher (PR 17)
# =============================================================
# After PR 17 the `username` field on /api/auth/login accepts BOTH
# a username (no `@`) and an email address. The server dispatches
# on `@`-in-input: with `@` → email lookup, without → username
# lookup. The two namespaces are provably disjoint (PR 16 forbids
# `@` in usernames), so this is unambiguous.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Case 1 — Login by username (the classic path).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Asserts]
jsonpath "$.access_token" exists
jsonpath "$.user.email" == "{{email}}"
# ─────────────────────────────────────────────────────────────
# Case 2 — Login by email (new path).
# The same DTO field, different lookup branch because
# the input contains `@`.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{email}}", "password": "{{password}}" }
HTTP 200
[Asserts]
jsonpath "$.access_token" exists
jsonpath "$.user.email" == "{{email}}"
# ─────────────────────────────────────────────────────────────
# Case 3 — Wrong password on the username path → uniform 403.
# Anti-enumeration: same error shape as unknown-user.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "definitely-wrong" }
HTTP 403
# ─────────────────────────────────────────────────────────────
# Case 4 — Wrong password on the email path → uniform 403.
# Same as Case 3 but with the email path. The error
# shape is identical regardless of which branch fired.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{email}}", "password": "definitely-wrong" }
HTTP 403
# ─────────────────────────────────────────────────────────────
# Case 5 — Unknown username → uniform 403, audit reason
# `unknown_user`. The visible response is identical
# to wrong-password (Case 3) so a probing attacker
# can't distinguish "user exists" from "user doesn't".
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "ghost-user-that-doesnt-exist", "password": "{{password}}" }
HTTP 403
# ─────────────────────────────────────────────────────────────
# Case 6 — Unknown email → uniform 403, same anti-enumeration
# guarantee as Case 5 but exercising the email branch.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "ghost@nowhere.invalid", "password": "{{password}}" }
HTTP 403
+1
View File
@@ -90,6 +90,7 @@ log "Server is ready."
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/files-folders.hurl" \
"$API_DIR/favorites.hurl" \
"$API_DIR/trash.hurl" \