Skip to content

Commit 4a14e36

Browse files
authored
Allow logging in using an email address (#4337)
2 parents 62741a0 + 87f7ba3 commit 4a14e36

File tree

18 files changed

+177
-12
lines changed

18 files changed

+177
-12
lines changed

crates/cli/src/util.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ pub fn site_config_from_config(
214214
captcha,
215215
minimum_password_complexity: password_config.minimum_complexity(),
216216
session_expiration,
217+
login_with_email_allowed: account_config.login_with_email_allowed,
217218
})
218219
}
219220

crates/config/src/sections/account.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ pub struct AccountConfig {
6666
/// `true`.
6767
#[serde(default = "default_true", skip_serializing_if = "is_default_true")]
6868
pub account_deactivation_allowed: bool,
69+
70+
/// Whether users can log in with their email address. Defaults to `false`.
71+
///
72+
/// This has no effect if password login is disabled.
73+
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
74+
pub login_with_email_allowed: bool,
6975
}
7076

7177
impl Default for AccountConfig {
@@ -77,6 +83,7 @@ impl Default for AccountConfig {
7783
password_change_allowed: default_true(),
7884
password_recovery_enabled: default_false(),
7985
account_deactivation_allowed: default_true(),
86+
login_with_email_allowed: default_false(),
8087
}
8188
}
8289
}
@@ -90,6 +97,7 @@ impl AccountConfig {
9097
&& is_default_true(&self.password_change_allowed)
9198
&& is_default_false(&self.password_recovery_enabled)
9299
&& is_default_true(&self.account_deactivation_allowed)
100+
&& is_default_false(&self.login_with_email_allowed)
93101
}
94102
}
95103

crates/data-model/src/site_config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,7 @@ pub struct SiteConfig {
8787
pub minimum_password_complexity: u8,
8888

8989
pub session_expiration: Option<SessionExpirationConfig>,
90+
91+
/// Whether users can log in with their email address.
92+
pub login_with_email_allowed: bool,
9093
}

crates/handlers/src/graphql/model/site_config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ pub struct SiteConfig {
5353
/// The exact scorer (including dictionaries and other data tables)
5454
/// in use is <https://crates.io/crates/zxcvbn>.
5555
minimum_password_complexity: u8,
56+
57+
/// Whether users can log in with their email address.
58+
login_with_email_allowed: bool,
5659
}
5760

5861
#[derive(SimpleObject)]
@@ -98,6 +101,7 @@ impl SiteConfig {
98101
password_registration_enabled: data_model.password_registration_enabled,
99102
account_deactivation_allowed: data_model.account_deactivation_allowed,
100103
minimum_password_complexity: data_model.minimum_password_complexity,
104+
login_with_email_allowed: data_model.login_with_email_allowed,
101105
}
102106
}
103107
}

crates/handlers/src/test_utils.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ pub fn test_site_config() -> SiteConfig {
141141
captcha: None,
142142
minimum_password_complexity: 1,
143143
session_expiration: None,
144+
login_with_email_allowed: true,
144145
}
145146
}
146147

crates/handlers/src/views/login.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,8 @@ pub(crate) async fn post(
187187
.unwrap_or(&form.username);
188188

189189
// First, lookup the user
190-
let Some(user) = repo.user().find_by_username(username).await? else {
190+
let Some(user) = get_user_by_email_or_by_username(site_config, &mut repo, username).await?
191+
else {
191192
let form_state = form_state.with_error_on_form(FormError::InvalidCredentials);
192193
PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]);
193194
return render(
@@ -337,6 +338,28 @@ pub(crate) async fn post(
337338
Ok((cookie_jar, reply).into_response())
338339
}
339340

341+
async fn get_user_by_email_or_by_username(
342+
site_config: SiteConfig,
343+
repo: &mut impl RepositoryAccess,
344+
username_or_email: &str,
345+
) -> Result<Option<mas_data_model::User>, Box<dyn std::error::Error>> {
346+
if site_config.login_with_email_allowed && username_or_email.contains('@') {
347+
let maybe_user_email = repo.user_email().find_by_email(username_or_email).await?;
348+
349+
if let Some(user_email) = maybe_user_email {
350+
let user = repo.user().lookup(user_email.user_id).await?;
351+
352+
if user.is_some() {
353+
return Ok(user);
354+
}
355+
}
356+
}
357+
358+
let user = repo.user().find_by_username(username_or_email).await?;
359+
360+
Ok(user)
361+
}
362+
340363
fn handle_login_hint(
341364
mut ctx: LoginContext,
342365
next: &PostAuthContext,

crates/storage-pg/.sqlx/query-f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec.json

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/storage-pg/src/user/email.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,43 @@ impl UserEmailRepository for PgUserEmailRepository<'_> {
191191
Ok(Some(user_email.into()))
192192
}
193193

194+
#[tracing::instrument(
195+
name = "db.user_email.find_by_email",
196+
skip_all,
197+
fields(
198+
db.query.text,
199+
user_email.email = email,
200+
),
201+
err,
202+
)]
203+
async fn find_by_email(&mut self, email: &str) -> Result<Option<UserEmail>, Self::Error> {
204+
let res = sqlx::query_as!(
205+
UserEmailLookup,
206+
r#"
207+
SELECT user_email_id
208+
, user_id
209+
, email
210+
, created_at
211+
FROM user_emails
212+
WHERE email = $1
213+
"#,
214+
email,
215+
)
216+
.traced()
217+
.fetch_all(&mut *self.conn)
218+
.await?;
219+
220+
if res.len() != 1 {
221+
return Ok(None);
222+
}
223+
224+
let Some(user_email) = res.into_iter().next() else {
225+
return Ok(None);
226+
};
227+
228+
Ok(Some(user_email.into()))
229+
}
230+
194231
#[tracing::instrument(
195232
name = "db.user_email.all",
196233
skip_all,

crates/storage/src/user/email.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ pub trait UserEmailRepository: Send + Sync {
9393
/// Returns [`Self::Error`] if the underlying repository fails
9494
async fn find(&mut self, user: &User, email: &str) -> Result<Option<UserEmail>, Self::Error>;
9595

96+
/// Lookup an [`UserEmail`] by its email address
97+
///
98+
/// Returns `None` if no matching [`UserEmail`] was found or if multiple
99+
/// [`UserEmail`] are found
100+
///
101+
/// # Parameters
102+
/// * `email`: The email address to lookup
103+
///
104+
/// # Errors
105+
///
106+
/// Returns [`Self::Error`] if the underlying repository fails
107+
async fn find_by_email(&mut self, email: &str) -> Result<Option<UserEmail>, Self::Error>;
108+
96109
/// Get all [`UserEmail`] of a [`User`]
97110
///
98111
/// # Parameters
@@ -298,6 +311,7 @@ pub trait UserEmailRepository: Send + Sync {
298311
repository_impl!(UserEmailRepository:
299312
async fn lookup(&mut self, id: Ulid) -> Result<Option<UserEmail>, Self::Error>;
300313
async fn find(&mut self, user: &User, email: &str) -> Result<Option<UserEmail>, Self::Error>;
314+
async fn find_by_email(&mut self, email: &str) -> Result<Option<UserEmail>, Self::Error>;
301315

302316
async fn all(&mut self, user: &User) -> Result<Vec<UserEmail>, Self::Error>;
303317
async fn list(

crates/templates/src/context/ext.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ impl SiteConfigExt for SiteConfig {
4747
password_registration: self.password_registration_enabled,
4848
password_login: self.password_login_enabled,
4949
account_recovery: self.account_recovery_allowed,
50+
login_with_email_allowed: self.login_with_email_allowed,
5051
}
5152
}
5253
}

crates/templates/src/context/features.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use minijinja::{
1212
};
1313

1414
/// Site features information.
15+
#[allow(clippy::struct_excessive_bools)]
1516
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1617
pub struct SiteFeatures {
1718
/// Whether local password-based registration is enabled.
@@ -22,6 +23,9 @@ pub struct SiteFeatures {
2223

2324
/// Whether email-based account recovery is enabled.
2425
pub account_recovery: bool,
26+
27+
/// Whether users can log in with their email address.
28+
pub login_with_email_allowed: bool,
2529
}
2630

2731
impl Object for SiteFeatures {
@@ -30,6 +34,7 @@ impl Object for SiteFeatures {
3034
"password_registration" => Some(Value::from(self.password_registration)),
3135
"password_login" => Some(Value::from(self.password_login)),
3236
"account_recovery" => Some(Value::from(self.account_recovery)),
37+
"login_with_email_allowed" => Some(Value::from(self.login_with_email_allowed)),
3338
_ => None,
3439
}
3540
}
@@ -39,6 +44,7 @@ impl Object for SiteFeatures {
3944
"password_registration",
4045
"password_login",
4146
"account_recovery",
47+
"login_with_email_allowed",
4248
])
4349
}
4450
}

crates/templates/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@ mod tests {
487487
password_login: true,
488488
password_registration: true,
489489
account_recovery: true,
490+
login_with_email_allowed: true,
490491
};
491492
let vite_manifest_path =
492493
Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json");

docs/config.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2504,6 +2504,10 @@
25042504
"account_deactivation_allowed": {
25052505
"description": "Whether users are allowed to delete their own account. Defaults to `true`.",
25062506
"type": "boolean"
2507+
},
2508+
"login_with_email_allowed": {
2509+
"description": "Whether users can log in with their email address. Defaults to `false`.\n\nThis has no effect if password login is disabled.",
2510+
"type": "boolean"
25072511
}
25082512
}
25092513
},

docs/reference/configuration.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,12 @@ account:
314314
#
315315
# Defaults to `true`.
316316
account_deactivation_allowed: true
317+
318+
# Whether users can log in with their email address.
319+
#
320+
# Defaults to `false`.
321+
# This has no effect if password login is disabled.
322+
login_with_email_allowed: false
317323
```
318324
319325
## `captcha`

frontend/schema.graphql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1662,6 +1662,10 @@ type SiteConfig implements Node {
16621662
"""
16631663
minimumPasswordComplexity: Int!
16641664
"""
1665+
Whether users can log in with their email address.
1666+
"""
1667+
loginWithEmailAllowed: Boolean!
1668+
"""
16651669
The ID of the site configuration.
16661670
"""
16671671
id: ID!

frontend/src/gql/graphql.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,8 @@ export type SiteConfig = Node & {
12181218
id: Scalars['ID']['output'];
12191219
/** Imprint to show in the footer. */
12201220
imprint?: Maybe<Scalars['String']['output']>;
1221+
/** Whether users can log in with their email address. */
1222+
loginWithEmailAllowed: Scalars['Boolean']['output'];
12211223
/**
12221224
* Minimum password complexity, from 0 to 4, in terms of a zxcvbn score.
12231225
* The exact scorer (including dictionaries and other data tables)

templates/pages/login.html

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,15 @@ <h1 class="title">{{ _("mas.login.headline") }}</h1>
4242

4343
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
4444

45-
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
46-
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" required />
47-
{% endcall %}
45+
{% if features.login_with_email_allowed %}
46+
{% call(f) field.field(label=_("mas.login.username_or_email"), name="username", form_state=form) %}
47+
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" required />
48+
{% endcall %}
49+
{% else %}
50+
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
51+
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" required />
52+
{% endcall %}
53+
{% endif %}
4854

4955
{% if features.password_login %}
5056
{% call(f) field.field(label=_("common.password"), name="password", form_state=form) %}

0 commit comments

Comments
 (0)