From ac24a0eda1b8c7a9f35db4b019ee3cb595075123 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 2 Jun 2026 21:46:01 +0200 Subject: [PATCH] feat(username|email): pass2: accept login via email orusername - login via (username or email) + password - hurl test to cover the feature --- src/application/dtos/user_dto.rs | 7 ++ .../services/auth_application_service.rs | 44 +++++----- static/locales/ar.json | 2 + static/locales/de.json | 2 + static/locales/en.json | 2 + static/locales/es.json | 2 + static/locales/fa.json | 2 + static/locales/fr.json | 2 + static/locales/hi.json | 2 + static/locales/it.json | 2 + static/locales/ja.json | 2 + static/locales/ko.json | 2 + static/locales/nl.json | 2 + static/locales/pl.json | 2 + static/locales/pt.json | 2 + static/locales/ru.json | 2 + static/locales/zh-TW.json | 2 + static/locales/zh.json | 2 + static/login.html | 14 ++-- tests/api/auth_login.hurl | 84 +++++++++++++++++++ tests/api/run.sh | 1 + 21 files changed, 155 insertions(+), 27 deletions(-) create mode 100644 tests/api/auth_login.hurl diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index a79cad37..4fa79ff6 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -67,6 +67,13 @@ impl From 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, } diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index eedb98bc..b93c1e2f 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -432,26 +432,30 @@ impl AuthApplicationService { } pub async fn login(&self, dto: LoginDto) -> Result { - // 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() { diff --git a/static/locales/ar.json b/static/locales/ar.json index f1d37eca..c3b7dc09 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -381,6 +381,8 @@ "login_title": "تسجيل الدخول", "username": "اسم المستخدم", "username_placeholder": "أدخل اسم المستخدم", + "login_identifier": "اسم المستخدم أو البريد الإلكتروني", + "login_identifier_placeholder": "أدخل اسم المستخدم أو البريد الإلكتروني", "password": "كلمة المرور", "password_placeholder": "أدخل كلمة المرور", "login_button": "تسجيل الدخول", diff --git a/static/locales/de.json b/static/locales/de.json index 92ce8246..f7c16a7a 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -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", diff --git a/static/locales/en.json b/static/locales/en.json index f390a653..2754eb01 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -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", diff --git a/static/locales/es.json b/static/locales/es.json index f3f3a143..a9ea765a 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -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", diff --git a/static/locales/fa.json b/static/locales/fa.json index 7be825d4..bb640cfb 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -381,6 +381,8 @@ "login_title": "ورود", "username": "نام‌کاربری", "username_placeholder": "نام‌کاربری خود را وارد کنید", + "login_identifier": "نام کاربری یا ایمیل", + "login_identifier_placeholder": "نام کاربری یا ایمیل خود را وارد کنید", "password": "گذرواژه", "password_placeholder": "گذرواژه خود را وارد کنید", "login_button": "ورود", diff --git a/static/locales/fr.json b/static/locales/fr.json index 334d62bc..23e552b6 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -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", diff --git a/static/locales/hi.json b/static/locales/hi.json index 310041e7..546e3666 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -381,6 +381,8 @@ "login_title": "साइन इन", "username": "उपयोगकर्ता नाम", "username_placeholder": "अपना उपयोगकर्ता नाम दर्ज करें", + "login_identifier": "उपयोगकर्ता नाम या ईमेल", + "login_identifier_placeholder": "अपना उपयोगकर्ता नाम या ईमेल दर्ज करें", "password": "पासवर्ड", "password_placeholder": "अपना पासवर्ड दर्ज करें", "login_button": "साइन इन", diff --git a/static/locales/it.json b/static/locales/it.json index c230e9f5..7e06c09d 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -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", diff --git a/static/locales/ja.json b/static/locales/ja.json index 5a715840..cec41168 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -381,6 +381,8 @@ "login_title": "サインイン", "username": "ユーザー名", "username_placeholder": "ユーザー名を入力", + "login_identifier": "ユーザー名またはメールアドレス", + "login_identifier_placeholder": "ユーザー名またはメールアドレスを入力", "password": "パスワード", "password_placeholder": "パスワードを入力", "login_button": "サインイン", diff --git a/static/locales/ko.json b/static/locales/ko.json index 760701f1..f6b5250a 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -381,6 +381,8 @@ "login_title": "로그인", "username": "사용자 이름", "username_placeholder": "사용자 이름을 입력하세요", + "login_identifier": "사용자 이름 또는 이메일", + "login_identifier_placeholder": "사용자 이름 또는 이메일을 입력하세요", "password": "비밀번호", "password_placeholder": "비밀번호를 입력하세요", "login_button": "로그인", diff --git a/static/locales/nl.json b/static/locales/nl.json index 38497730..1926d174 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -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", diff --git a/static/locales/pl.json b/static/locales/pl.json index fad1af80..e9164225 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -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ę", diff --git a/static/locales/pt.json b/static/locales/pt.json index b13e93b5..db3175bb 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -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", diff --git a/static/locales/ru.json b/static/locales/ru.json index 03858280..cabd5af2 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -381,6 +381,8 @@ "login_title": "Вход", "username": "Имя пользователя", "username_placeholder": "Введите имя пользователя", + "login_identifier": "Имя пользователя или email", + "login_identifier_placeholder": "Введите имя пользователя или email", "password": "Пароль", "password_placeholder": "Введите пароль", "login_button": "Войти", diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index a0de4151..efe99260 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -381,6 +381,8 @@ "login_title": "登入", "username": "使用者名稱", "username_placeholder": "輸入你的使用者名稱", + "login_identifier": "使用者名稱或電子郵件", + "login_identifier_placeholder": "請輸入使用者名稱或電子郵件", "password": "密碼", "password_placeholder": "輸入你的密碼", "login_button": "登入", diff --git a/static/locales/zh.json b/static/locales/zh.json index 7cf034e4..ce67e137 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -381,6 +381,8 @@ "login_title": "登录", "username": "用户名", "username_placeholder": "输入你的用户名", + "login_identifier": "用户名或邮箱", + "login_identifier_placeholder": "请输入用户名或邮箱", "password": "密码", "password_placeholder": "输入你的密码", "login_button": "登录", diff --git a/static/login.html b/static/login.html index dd88afc3..757d80ba 100644 --- a/static/login.html +++ b/static/login.html @@ -72,13 +72,13 @@
- - Username or email +
diff --git a/tests/api/auth_login.hurl b/tests/api/auth_login.hurl new file mode 100644 index 00000000..3006dd50 --- /dev/null +++ b/tests/api/auth_login.hurl @@ -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 diff --git a/tests/api/run.sh b/tests/api/run.sh index ec4d1427..78b07dec 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -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" \