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
127 changes: 117 additions & 10 deletions crates/handlers/src/graphql/mutations/user_email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,75 @@

use anyhow::Context as _;
use async_graphql::{Context, Description, Enum, ID, InputObject, Object};
use mas_data_model::SiteConfig;
use mas_i18n::DataLocale;
use mas_storage::{
RepositoryAccess,
BoxRepository, RepositoryAccess,
queue::{ProvisionUserJob, QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob},
user::{UserEmailFilter, UserEmailRepository, UserRepository},
};
use zeroize::Zeroizing;

use crate::graphql::{
model::{NodeType, User, UserEmail, UserEmailAuthentication},
state::ContextExt,
use crate::{
graphql::{
Requester,
model::{NodeType, User, UserEmail, UserEmailAuthentication},
state::ContextExt,
},
passwords::PasswordManager,
};

/// Check the password if neeed
///
/// Returns true if password verification is not needed, or if the password is
/// correct. Returns false if the password is incorrect or missing.
async fn verify_password_if_needed(
requester: &Requester,
config: &SiteConfig,
password_manager: &PasswordManager,
password: Option<String>,
user: &mas_data_model::User,
repo: &mut BoxRepository,
) -> Result<bool, async_graphql::Error> {
// If the requester is admin, they don't need to provide a password
if requester.is_admin() {
return Ok(true);
}

// If password login is disabled, assume we don't want the user to reauth
if !config.password_login_enabled {
return Ok(true);
}

// Else we need to check if the user has a password
let Some(user_password) = repo
.user_password()
.active(user)
.await
.context("Failed to load user password")?
else {
// User has no password, so we don't need to verify the password
return Ok(true);
};

let Some(password) = password else {
// There is a password on the user, but not provided in the input
return Ok(false);
};

let password = Zeroizing::new(password.into_bytes());

let res = password_manager
.verify(
user_password.version,
password,
user_password.hashed_password,
)
.await;

Ok(res.is_ok())
}

#[derive(Default)]
pub struct UserEmailMutations {
_private: (),
Expand Down Expand Up @@ -120,6 +177,10 @@ impl AddEmailPayload {
struct RemoveEmailInput {
/// The ID of the email address to remove
user_email_id: ID,

/// The user's current password. This is required if the user is not an
/// admin and it has a password on its account.
password: Option<String>,
}

/// The status of the `removeEmail` mutation
Expand All @@ -130,13 +191,17 @@ enum RemoveEmailStatus {

/// The email address was not found
NotFound,

/// The password provided is incorrect
IncorrectPassword,
}

/// The payload of the `removeEmail` mutation
#[derive(Description)]
enum RemoveEmailPayload {
Removed(mas_data_model::UserEmail),
NotFound,
IncorrectPassword,
}

#[Object(use_type_description)]
Expand All @@ -146,27 +211,31 @@ impl RemoveEmailPayload {
match self {
RemoveEmailPayload::Removed(_) => RemoveEmailStatus::Removed,
RemoveEmailPayload::NotFound => RemoveEmailStatus::NotFound,
RemoveEmailPayload::IncorrectPassword => RemoveEmailStatus::IncorrectPassword,
}
}

/// The email address that was removed
async fn email(&self) -> Option<UserEmail> {
match self {
RemoveEmailPayload::Removed(email) => Some(UserEmail(email.clone())),
RemoveEmailPayload::NotFound => None,
RemoveEmailPayload::NotFound | RemoveEmailPayload::IncorrectPassword => None,
}
}

/// The user to whom the email address belonged
async fn user(&self, ctx: &Context<'_>) -> Result<Option<User>, async_graphql::Error> {
let state = ctx.state();
let mut repo = state.repository().await?;

let user_id = match self {
RemoveEmailPayload::Removed(email) => email.user_id,
RemoveEmailPayload::NotFound => return Ok(None),
RemoveEmailPayload::NotFound | RemoveEmailPayload::IncorrectPassword => {
return Ok(None);
}
};

let mut repo = state.repository().await?;

let user = repo
.user()
.lookup(user_id)
Expand Down Expand Up @@ -226,6 +295,10 @@ struct StartEmailAuthenticationInput {
/// The email address to add to the account
email: String,

/// The user's current password. This is required if the user has a password
/// on its account.
password: Option<String>,

/// The language to use for the email
#[graphql(default = "en")]
language: String,
Expand All @@ -244,6 +317,8 @@ enum StartEmailAuthenticationStatus {
Denied,
/// The email address is already in use on this account
InUse,
/// The password provided is incorrect
IncorrectPassword,
}

/// The payload of the `startEmailAuthentication` mutation
Expand All @@ -256,6 +331,7 @@ enum StartEmailAuthenticationPayload {
violations: Vec<mas_policy::Violation>,
},
InUse,
IncorrectPassword,
}

#[Object(use_type_description)]
Expand All @@ -268,16 +344,19 @@ impl StartEmailAuthenticationPayload {
Self::RateLimited => StartEmailAuthenticationStatus::RateLimited,
Self::Denied { .. } => StartEmailAuthenticationStatus::Denied,
Self::InUse => StartEmailAuthenticationStatus::InUse,
Self::IncorrectPassword => StartEmailAuthenticationStatus::IncorrectPassword,
}
}

/// The email authentication session that was started
async fn authentication(&self) -> Option<&UserEmailAuthentication> {
match self {
Self::Started(authentication) => Some(authentication),
Self::InvalidEmailAddress | Self::RateLimited | Self::Denied { .. } | Self::InUse => {
None
}
Self::InvalidEmailAddress
| Self::RateLimited
| Self::Denied { .. }
| Self::InUse
| Self::IncorrectPassword => None,
}
}

Expand Down Expand Up @@ -494,6 +573,20 @@ impl UserEmailMutations {
.await?
.context("Failed to load user")?;

// Validate the password input if needed
if !verify_password_if_needed(
requester,
state.site_config(),
&state.password_manager(),
input.password,
&user,
&mut repo,
)
.await?
{
return Ok(RemoveEmailPayload::IncorrectPassword);
}

// TODO: don't allow removing the last email address

repo.user_email().remove(user_email.clone()).await?;
Expand Down Expand Up @@ -627,6 +720,20 @@ impl UserEmailMutations {
});
}

// Validate the password input if needed
if !verify_password_if_needed(
requester,
state.site_config(),
&state.password_manager(),
input.password,
&browser_session.user,
&mut repo,
)
.await?
{
return Ok(StartEmailAuthenticationPayload::IncorrectPassword);
}

// Create a new authentication session
let authentication = repo
.user_email()
Expand Down
10 changes: 8 additions & 2 deletions frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"clear": "Clear",
"close": "Close",
"collapse": "Collapse",
"confirm": "Confirm",
"continue": "Continue",
"edit": "Edit",
"expand": "Expand",
Expand All @@ -27,6 +28,7 @@
"e2ee": "End-to-end encryption",
"loading": "Loading…",
"next": "Next",
"password": "Password",
"previous": "Previous",
"saved": "Saved",
"saving": "Saving…"
Expand Down Expand Up @@ -57,7 +59,9 @@
"email_field_help": "Add an alternative email you can use to access this account.",
"email_field_label": "Add email",
"email_in_use_error": "The entered email is already in use",
"email_invalid_error": "The entered email is invalid"
"email_invalid_error": "The entered email is invalid",
"incorrect_password_error": "Incorrect password, please try again",
"password_confirmation": "Confirm your account password to add this email address"
},
"browser_session_details": {
"current_badge": "Current"
Expand Down Expand Up @@ -258,7 +262,9 @@
"user_email": {
"delete_button_confirmation_modal": {
"action": "Delete email",
"body": "Delete this email?"
"body": "Delete this email?",
"incorrect_password": "Incorrect password, please try again",
"password_confirmation": "Confirm your account password to delete this email address"
},
"delete_button_title": "Remove email address",
"email": "Email"
Expand Down
18 changes: 18 additions & 0 deletions frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,11 @@ input RemoveEmailInput {
The ID of the email address to remove
"""
userEmailId: ID!
"""
The user's current password. This is required if the user is not an
admin and it has a password on its account.
"""
password: String
}

"""
Expand Down Expand Up @@ -1235,6 +1240,10 @@ enum RemoveEmailStatus {
The email address was not found
"""
NOT_FOUND
"""
The password provided is incorrect
"""
INCORRECT_PASSWORD
}

"""
Expand Down Expand Up @@ -1610,6 +1619,11 @@ input StartEmailAuthenticationInput {
"""
email: String!
"""
The user's current password. This is required if the user has a password
on its account.
"""
password: String
"""
The language to use for the email
"""
language: String! = "en"
Expand Down Expand Up @@ -1657,6 +1671,10 @@ enum StartEmailAuthenticationStatus {
The email address is already in use on this account
"""
IN_USE
"""
The password provided is incorrect
"""
INCORRECT_PASSWORD
}

"""
Expand Down
Loading
Loading