Skip to content

Commit f2a47f9

Browse files
committed
add login by email + feature flag
1 parent 62741a0 commit f2a47f9

File tree

20 files changed

+188
-14
lines changed

20 files changed

+188
-14
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: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ 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? else {
191191
let form_state = form_state.with_error_on_form(FormError::InvalidCredentials);
192192
PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]);
193193
return render(
@@ -337,6 +337,37 @@ pub(crate) async fn post(
337337
Ok((cookie_jar, reply).into_response())
338338
}
339339

340+
async fn get_user_by_email_or_by_username(
341+
site_config: SiteConfig,
342+
repo: &mut impl RepositoryAccess,
343+
username_or_email: &str,
344+
) -> Result<Option<mas_data_model::User>, Box<dyn std::error::Error>> {
345+
if site_config.login_with_email_allowed && username_or_email.contains('@') {
346+
let maybe_user_email = repo
347+
.user_email()
348+
.find_by_email(username_or_email)
349+
.await?;
350+
351+
if let Some(user_email) = maybe_user_email {
352+
let user = repo
353+
.user()
354+
.lookup(user_email.user_id)
355+
.await?;
356+
357+
if user.is_some() {
358+
return Ok(user);
359+
}
360+
};
361+
}
362+
363+
let user = repo
364+
.user()
365+
.find_by_username(username_or_email)
366+
.await?;
367+
368+
Ok(user)
369+
}
370+
340371
fn handle_login_hint(
341372
mut ctx: LoginContext,
342373
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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ 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 [`UserEmail`] are found
99+
///
100+
/// # Parameters
101+
/// * `email`: The email address to lookup
102+
///
103+
/// # Errors
104+
///
105+
/// Returns [`Self::Error`] if the underlying repository fails
106+
async fn find_by_email(&mut self, email: &str) -> Result<Option<UserEmail>, Self::Error>;
107+
96108
/// Get all [`UserEmail`] of a [`User`]
97109
///
98110
/// # Parameters
@@ -298,6 +310,7 @@ pub trait UserEmailRepository: Send + Sync {
298310
repository_impl!(UserEmailRepository:
299311
async fn lookup(&mut self, id: Ulid) -> Result<Option<UserEmail>, Self::Error>;
300312
async fn find(&mut self, user: &User, email: &str) -> Result<Option<UserEmail>, Self::Error>;
313+
async fn find_by_email(&mut self, email: &str) -> Result<Option<UserEmail>, Self::Error>;
301314

302315
async fn all(&mut self, user: &User) -> Result<Vec<UserEmail>, Self::Error>;
303316
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
}

0 commit comments

Comments
 (0)