Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/cli/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}

Expand Down
8 changes: 8 additions & 0 deletions crates/config/src/sections/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(),
}
}
}
Expand All @@ -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)
}
}

Expand Down
3 changes: 3 additions & 0 deletions crates/data-model/src/site_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
4 changes: 4 additions & 0 deletions crates/handlers/src/graphql/model/site_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/handlers/src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ pub fn test_site_config() -> SiteConfig {
captcha: None,
minimum_password_complexity: 1,
session_expiration: None,
login_with_email_allowed: true,
}
}

Expand Down
25 changes: 24 additions & 1 deletion crates/handlers/src/views/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<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,
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions crates/storage-pg/src/user/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions crates/storage/src/user/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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
Expand Down Expand Up @@ -298,6 +311,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(
Expand Down
1 change: 1 addition & 0 deletions crates/templates/src/context/ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 6 additions & 0 deletions crates/templates/src/context/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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,
}
}
Expand All @@ -39,6 +44,7 @@ impl Object for SiteFeatures {
"password_registration",
"password_login",
"account_recovery",
"login_with_email_allowed",
])
}
}
1 change: 1 addition & 0 deletions crates/templates/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions docs/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 4 additions & 0 deletions frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/gql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 9 additions & 3 deletions templates/pages/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,15 @@ <h1 class="title">{{ _("mas.login.headline") }}</h1>

<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=_("mas.login.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) %}
Expand Down
Loading
Loading