add login by email + feature flag

This commit is contained in:
mcalinghee
2025-03-27 18:00:48 +01:00
parent 62741a0e36
commit f2a47f9a88
20 changed files with 188 additions and 14 deletions
+1
View File
@@ -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,
})
}
+8
View File
@@ -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)
}
}
+3
View File
@@ -87,4 +87,7 @@ pub struct SiteConfig {
pub minimum_password_complexity: u8,
pub session_expiration: Option<SessionExpirationConfig>,
/// Whether users can log in with their email address.
pub login_with_email_allowed: bool,
}
@@ -53,6 +53,9 @@ pub struct SiteConfig {
/// The exact scorer (including dictionaries and other data tables)
/// in use is <https://crates.io/crates/zxcvbn>.
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,
}
}
}
+1
View File
@@ -141,6 +141,7 @@ pub fn test_site_config() -> SiteConfig {
captcha: None,
minimum_password_complexity: 1,
session_expiration: None,
login_with_email_allowed: true,
}
}
+32 -1
View File
@@ -187,7 +187,7 @@ 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 +337,37 @@ 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<Option<mas_data_model::User>, Box<dyn std::error::Error>> {
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,
@@ -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"
}
+37
View File
@@ -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<Option<UserEmail>, 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,
+13
View File
@@ -93,6 +93,18 @@ pub trait UserEmailRepository: Send + Sync {
/// Returns [`Self::Error`] if the underlying repository fails
async fn find(&mut self, user: &User, email: &str) -> Result<Option<UserEmail>, 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<Option<UserEmail>, Self::Error>;
/// Get all [`UserEmail`] of a [`User`]
///
/// # Parameters
@@ -298,6 +310,7 @@ pub trait UserEmailRepository: Send + Sync {
repository_impl!(UserEmailRepository:
async fn lookup(&mut self, id: Ulid) -> Result<Option<UserEmail>, Self::Error>;
async fn find(&mut self, user: &User, email: &str) -> Result<Option<UserEmail>, Self::Error>;
async fn find_by_email(&mut self, email: &str) -> Result<Option<UserEmail>, Self::Error>;
async fn all(&mut self, user: &User) -> Result<Vec<UserEmail>, Self::Error>;
async fn list(
+1
View File
@@ -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,
}
}
}
+6
View File
@@ -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",
])
}
}
+1
View File
@@ -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");
+4
View File
@@ -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"
}
}
},
+6
View File
@@ -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`
+4
View File
@@ -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!
+2
View File
@@ -1218,6 +1218,8 @@ export type SiteConfig = Node & {
id: Scalars['ID']['output'];
/** Imprint to show in the footer. */
imprint?: Maybe<Scalars['String']['output']>;
/** 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)
+9 -3
View File
@@ -42,9 +42,15 @@ Please see LICENSE in the repository root for full details.
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" required />
{% endcall %}
{% if features.login_with_email_allowed %}
{% call(f) field.field(label=_("common.username_or_email"), name="username", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" required />
{% endcall %}
{% else %}
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" required />
{% endcall %}
{% endif %}
{% if features.password_login %}
{% call(f) field.field(label=_("common.password"), name="password", form_state=form) %}
+2 -1
View File
@@ -32,7 +32,8 @@
"mxid": "Matrix-ID",
"password": "Passwort",
"password_confirm": "Passwort wiederholen",
"username": "Benutzername"
"username": "Benutzername",
"username_or_email": "Benutzername oder E-Mail-Adresse"
},
"error": {
"unexpected": "Unerwarteter Fehler"
+12 -8
View File
@@ -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,11 @@
},
"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"
},
"username_or_email": "Username or Email Address",
"@username_or_email": {
"context": "pages/login.html:46:37-66"
}
},
"error": {
@@ -403,11 +407,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 +420,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 +439,7 @@
},
"no_login_methods": "No login methods available.",
"@no_login_methods": {
"context": "pages/login.html:94:11-42"
"context": "pages/login.html:100:11-42"
}
},
"navbar": {
+2 -1
View File
@@ -32,7 +32,8 @@
"mxid": "Matrix ID",
"password": "Mot de passe",
"password_confirm": "Confirmer le mot de passe",
"username": "Nom dutilisateur"
"username": "Nom dutilisateur",
"username_or_email": "Nom dutilisateur ou adresse e-mail"
},
"error": {
"unexpected": "Erreur inattendue"