diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 796d6696d..2daad2e91 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -214,6 +214,7 @@ pub fn site_config_from_config( captcha, minimum_password_complexity: password_config.minimum_complexity(), session_expiration, + login_with_email_allowed: account_config.login_with_email_allowed, }) } diff --git a/crates/config/src/sections/account.rs b/crates/config/src/sections/account.rs index 987ff5741..28733c7ef 100644 --- a/crates/config/src/sections/account.rs +++ b/crates/config/src/sections/account.rs @@ -66,6 +66,12 @@ pub struct AccountConfig { /// `true`. #[serde(default = "default_true", skip_serializing_if = "is_default_true")] pub account_deactivation_allowed: bool, + + /// Whether users can log in with their email address. Defaults to `false`. + /// + /// This has no effect if password login is disabled. + #[serde(default = "default_false", skip_serializing_if = "is_default_false")] + pub login_with_email_allowed: bool, } impl Default for AccountConfig { @@ -77,6 +83,7 @@ impl Default for AccountConfig { password_change_allowed: default_true(), password_recovery_enabled: default_false(), account_deactivation_allowed: default_true(), + login_with_email_allowed: default_false(), } } } @@ -90,6 +97,7 @@ impl AccountConfig { && is_default_true(&self.password_change_allowed) && is_default_false(&self.password_recovery_enabled) && is_default_true(&self.account_deactivation_allowed) + && is_default_false(&self.login_with_email_allowed) } } diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index 4688c0f11..de07a03c5 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -87,4 +87,7 @@ pub struct SiteConfig { pub minimum_password_complexity: u8, pub session_expiration: Option, + + /// Whether users can log in with their email address. + pub login_with_email_allowed: bool, } diff --git a/crates/handlers/src/graphql/model/site_config.rs b/crates/handlers/src/graphql/model/site_config.rs index 598c0aabc..02ba26fa2 100644 --- a/crates/handlers/src/graphql/model/site_config.rs +++ b/crates/handlers/src/graphql/model/site_config.rs @@ -53,6 +53,9 @@ pub struct SiteConfig { /// The exact scorer (including dictionaries and other data tables) /// in use is . minimum_password_complexity: u8, + + /// Whether users can log in with their email address. + login_with_email_allowed: bool, } #[derive(SimpleObject)] @@ -98,6 +101,7 @@ impl SiteConfig { password_registration_enabled: data_model.password_registration_enabled, account_deactivation_allowed: data_model.account_deactivation_allowed, minimum_password_complexity: data_model.minimum_password_complexity, + login_with_email_allowed: data_model.login_with_email_allowed, } } } diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 1d333a0e8..b6d9fba9d 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -141,6 +141,7 @@ pub fn test_site_config() -> SiteConfig { captcha: None, minimum_password_complexity: 1, session_expiration: None, + login_with_email_allowed: true, } } diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 6d195df05..869e9a89d 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -187,7 +187,8 @@ pub(crate) async fn post( .unwrap_or(&form.username); // First, lookup the user - let Some(user) = repo.user().find_by_username(username).await? else { + let Some(user) = get_user_by_email_or_by_username(site_config, &mut repo, username).await? + else { let form_state = form_state.with_error_on_form(FormError::InvalidCredentials); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); return render( @@ -337,6 +338,28 @@ pub(crate) async fn post( Ok((cookie_jar, reply).into_response()) } +async fn get_user_by_email_or_by_username( + site_config: SiteConfig, + repo: &mut impl RepositoryAccess, + username_or_email: &str, +) -> Result, Box> { + if site_config.login_with_email_allowed && username_or_email.contains('@') { + let maybe_user_email = repo.user_email().find_by_email(username_or_email).await?; + + if let Some(user_email) = maybe_user_email { + let user = repo.user().lookup(user_email.user_id).await?; + + if user.is_some() { + return Ok(user); + } + } + } + + let user = repo.user().find_by_username(username_or_email).await?; + + Ok(user) +} + fn handle_login_hint( mut ctx: LoginContext, next: &PostAuthContext, diff --git a/crates/storage-pg/.sqlx/query-f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec.json b/crates/storage-pg/.sqlx/query-f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec.json new file mode 100644 index 000000000..cc7f8d1af --- /dev/null +++ b/crates/storage-pg/.sqlx/query-f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n FROM user_emails\n WHERE email = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_email_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec" +} diff --git a/crates/storage-pg/src/user/email.rs b/crates/storage-pg/src/user/email.rs index 7cb36991f..ad8afd6a8 100644 --- a/crates/storage-pg/src/user/email.rs +++ b/crates/storage-pg/src/user/email.rs @@ -191,6 +191,43 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { Ok(Some(user_email.into())) } + #[tracing::instrument( + name = "db.user_email.find_by_email", + skip_all, + fields( + db.query.text, + user_email.email = email, + ), + err, + )] + async fn find_by_email(&mut self, email: &str) -> Result, Self::Error> { + let res = sqlx::query_as!( + UserEmailLookup, + r#" + SELECT user_email_id + , user_id + , email + , created_at + FROM user_emails + WHERE email = $1 + "#, + email, + ) + .traced() + .fetch_all(&mut *self.conn) + .await?; + + if res.len() != 1 { + return Ok(None); + } + + let Some(user_email) = res.into_iter().next() else { + return Ok(None); + }; + + Ok(Some(user_email.into())) + } + #[tracing::instrument( name = "db.user_email.all", skip_all, diff --git a/crates/storage/src/user/email.rs b/crates/storage/src/user/email.rs index 4fa559508..4cdc8d665 100644 --- a/crates/storage/src/user/email.rs +++ b/crates/storage/src/user/email.rs @@ -93,6 +93,19 @@ pub trait UserEmailRepository: Send + Sync { /// Returns [`Self::Error`] if the underlying repository fails async fn find(&mut self, user: &User, email: &str) -> Result, Self::Error>; + /// Lookup an [`UserEmail`] by its email address + /// + /// Returns `None` if no matching [`UserEmail`] was found or if multiple + /// [`UserEmail`] are found + /// + /// # Parameters + /// * `email`: The email address to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn find_by_email(&mut self, email: &str) -> Result, Self::Error>; + /// Get all [`UserEmail`] of a [`User`] /// /// # Parameters @@ -298,6 +311,7 @@ pub trait UserEmailRepository: Send + Sync { repository_impl!(UserEmailRepository: async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; async fn find(&mut self, user: &User, email: &str) -> Result, Self::Error>; + async fn find_by_email(&mut self, email: &str) -> Result, Self::Error>; async fn all(&mut self, user: &User) -> Result, Self::Error>; async fn list( diff --git a/crates/templates/src/context/ext.rs b/crates/templates/src/context/ext.rs index 3686c3bab..5a94430a7 100644 --- a/crates/templates/src/context/ext.rs +++ b/crates/templates/src/context/ext.rs @@ -47,6 +47,7 @@ impl SiteConfigExt for SiteConfig { password_registration: self.password_registration_enabled, password_login: self.password_login_enabled, account_recovery: self.account_recovery_allowed, + login_with_email_allowed: self.login_with_email_allowed, } } } diff --git a/crates/templates/src/context/features.rs b/crates/templates/src/context/features.rs index c9d30095d..a493e0cee 100644 --- a/crates/templates/src/context/features.rs +++ b/crates/templates/src/context/features.rs @@ -12,6 +12,7 @@ use minijinja::{ }; /// Site features information. +#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SiteFeatures { /// Whether local password-based registration is enabled. @@ -22,6 +23,9 @@ pub struct SiteFeatures { /// Whether email-based account recovery is enabled. pub account_recovery: bool, + + /// Whether users can log in with their email address. + pub login_with_email_allowed: bool, } impl Object for SiteFeatures { @@ -30,6 +34,7 @@ impl Object for SiteFeatures { "password_registration" => Some(Value::from(self.password_registration)), "password_login" => Some(Value::from(self.password_login)), "account_recovery" => Some(Value::from(self.account_recovery)), + "login_with_email_allowed" => Some(Value::from(self.login_with_email_allowed)), _ => None, } } @@ -39,6 +44,7 @@ impl Object for SiteFeatures { "password_registration", "password_login", "account_recovery", + "login_with_email_allowed", ]) } } diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 982b3fc02..754a6834f 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -487,6 +487,7 @@ mod tests { password_login: true, password_registration: true, account_recovery: true, + login_with_email_allowed: true, }; let vite_manifest_path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json"); diff --git a/docs/config.schema.json b/docs/config.schema.json index 2328a64be..e49a75754 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2504,6 +2504,10 @@ "account_deactivation_allowed": { "description": "Whether users are allowed to delete their own account. Defaults to `true`.", "type": "boolean" + }, + "login_with_email_allowed": { + "description": "Whether users can log in with their email address. Defaults to `false`.\n\nThis has no effect if password login is disabled.", + "type": "boolean" } } }, diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 8eb0d44b2..30dbbfca9 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -314,6 +314,12 @@ account: # # Defaults to `true`. account_deactivation_allowed: true + + # Whether users can log in with their email address. + # + # Defaults to `false`. + # This has no effect if password login is disabled. + login_with_email_allowed: false ``` ## `captcha` diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 9cf0ca600..4fdc85332 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -1662,6 +1662,10 @@ type SiteConfig implements Node { """ minimumPasswordComplexity: Int! """ + Whether users can log in with their email address. + """ + loginWithEmailAllowed: Boolean! + """ The ID of the site configuration. """ id: ID! diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 7e13fed3d..6bdb8d33f 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1218,6 +1218,8 @@ export type SiteConfig = Node & { id: Scalars['ID']['output']; /** Imprint to show in the footer. */ imprint?: Maybe; + /** Whether users can log in with their email address. */ + loginWithEmailAllowed: Scalars['Boolean']['output']; /** * Minimum password complexity, from 0 to 4, in terms of a zxcvbn score. * The exact scorer (including dictionaries and other data tables) diff --git a/templates/pages/login.html b/templates/pages/login.html index 3341e9f5d..637b0a79a 100644 --- a/templates/pages/login.html +++ b/templates/pages/login.html @@ -42,9 +42,15 @@

{{ _("mas.login.headline") }}

- {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} - - {% endcall %} + {% if features.login_with_email_allowed %} + {% call(f) field.field(label=_("mas.login.username_or_email"), name="username", form_state=form) %} + + {% endcall %} + {% else %} + {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} + + {% endcall %} + {% endif %} {% if features.password_login %} {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} diff --git a/translations/en.json b/translations/en.json index 47e1cfde5..8c4a76e1c 100644 --- a/translations/en.json +++ b/translations/en.json @@ -10,11 +10,11 @@ }, "continue": "Continue", "@continue": { - "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:123:13-33, pages/device_link.html:40:26-46, pages/login.html:62:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" + "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:123:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" }, "create_account": "Create Account", "@create_account": { - "context": "pages/login.html:88:33-59, pages/upstream_oauth2/do_register.html:192:26-52" + "context": "pages/login.html:94:33-59, pages/upstream_oauth2/do_register.html:192:26-52" }, "sign_in": "Sign in", "@sign_in": { @@ -91,7 +91,7 @@ }, "password": "Password", "@password": { - "context": "pages/login.html:50:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53" + "context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53" }, "password_confirm": "Confirm password", "@password_confirm": { @@ -99,7 +99,7 @@ }, "username": "Username", "@username": { - "context": "pages/login.html:45:35-55, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" + "context": "pages/login.html:50:37-57, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" } }, "error": { @@ -403,11 +403,11 @@ "login": { "call_to_register": "Don't have an account yet?", "@call_to_register": { - "context": "pages/login.html:84:13-44" + "context": "pages/login.html:90:13-44" }, "continue_with_provider": "Continue with %(provider)s", "@continue_with_provider": { - "context": "pages/login.html:75:15-67, pages/register/index.html:53:15-67", + "context": "pages/login.html:81:15-67, pages/register/index.html:53:15-67", "description": "Button to log in with an upstream provider" }, "description": "Please sign in to continue:", @@ -416,7 +416,7 @@ }, "forgot_password": "Forgot password?", "@forgot_password": { - "context": "pages/login.html:55:35-65", + "context": "pages/login.html:61:35-65", "description": "On the login page, link to the account recovery process" }, "headline": "Sign in", @@ -435,7 +435,11 @@ }, "no_login_methods": "No login methods available.", "@no_login_methods": { - "context": "pages/login.html:94:11-42" + "context": "pages/login.html:100:11-42" + }, + "username_or_email": "Username or Email", + "@username_or_email": { + "context": "pages/login.html:46:37-69" } }, "navbar": {