Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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 @@ -207,6 +207,7 @@ pub fn site_config_from_config(
&& account_config.password_change_allowed,
account_recovery_allowed: password_config.enabled()
&& account_config.password_recovery_enabled,
account_deactivation_allowed: account_config.account_deactivation_allowed,
captcha,
minimum_password_complexity: password_config.minimum_complexity(),
session_expiration,
Expand Down
7 changes: 7 additions & 0 deletions crates/config/src/sections/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ pub struct AccountConfig {
/// This has no effect if password login is disabled.
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
pub password_recovery_enabled: bool,

/// Whether users are allowed to delete their own account. Defaults to
/// `false`.
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
pub account_deactivation_allowed: bool,
}

impl Default for AccountConfig {
Expand All @@ -71,6 +76,7 @@ impl Default for AccountConfig {
password_registration_enabled: default_false(),
password_change_allowed: default_true(),
password_recovery_enabled: default_false(),
account_deactivation_allowed: default_false(),
}
}
}
Expand All @@ -83,6 +89,7 @@ impl AccountConfig {
&& is_default_true(&self.displayname_change_allowed)
&& is_default_true(&self.password_change_allowed)
&& is_default_false(&self.password_recovery_enabled)
&& is_default_false(&self.account_deactivation_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 @@ -76,6 +76,9 @@ pub struct SiteConfig {
/// Whether users can recover their account via email.
pub account_recovery_allowed: bool,

/// Whether users can delete their own account.
pub account_deactivation_allowed: bool,

/// Captcha configuration
pub captcha: Option<CaptchaConfig>,

Expand Down
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 @@ -46,6 +46,9 @@ pub struct SiteConfig {
/// Whether passwords are enabled and users can register using a password.
password_registration_enabled: bool,

/// Whether users can delete their own account.
account_deactivation_allowed: bool,

/// Minimum password complexity, from 0 to 4, in terms of a zxcvbn score.
/// The exact scorer (including dictionaries and other data tables)
/// in use is <https://crates.io/crates/zxcvbn>.
Expand Down Expand Up @@ -93,6 +96,7 @@ impl SiteConfig {
password_login_enabled: data_model.password_login_enabled,
password_change_allowed: data_model.password_change_allowed,
password_registration_enabled: data_model.password_registration_enabled,
account_deactivation_allowed: data_model.account_deactivation_allowed,
minimum_password_complexity: data_model.minimum_password_complexity,
}
}
Expand Down
58 changes: 58 additions & 0 deletions crates/handlers/src/graphql/mutations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ mod oauth2_session;
mod user;
mod user_email;

use anyhow::Context as _;
use async_graphql::MergedObject;
use mas_data_model::SiteConfig;
use mas_storage::BoxRepository;
use zeroize::Zeroizing;

use super::Requester;
use crate::passwords::PasswordManager;

/// The mutations root of the GraphQL interface.
#[derive(Default, MergedObject)]
Expand All @@ -30,3 +37,54 @@ impl Mutation {
Self::default()
}
}

/// 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())
}
109 changes: 109 additions & 0 deletions crates/handlers/src/graphql/mutations/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use ulid::Ulid;
use url::Url;
use zeroize::Zeroizing;

use super::verify_password_if_needed;
use crate::graphql::{
UserId,
model::{NodeType, User},
Expand Down Expand Up @@ -383,6 +384,54 @@ impl ResendRecoveryEmailPayload {
}
}

/// The input for the `deactivateUser` mutation.
#[derive(InputObject)]
pub struct DeactivateUserInput {
/// Whether to ask the homeserver to GDPR-erase the user
hs_erase: bool,

/// The password of the user to deactivate.
password: Option<String>,
}

/// The payload for the `deactivateUser` mutation.
#[derive(Description)]
pub enum DeactivateUserPayload {
/// The user was deactivated.
Deactivated(mas_data_model::User),

/// The password was wrong or missing.
IncorrectPassword,
}

/// The status of the `deactivateUser` mutation.
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
pub enum DeactivateUserStatus {
/// The user was deactivated.
Deactivated,

/// The password was wrong.
IncorrectPassword,
}

#[Object(use_type_description)]
impl DeactivateUserPayload {
/// Status of the operation
async fn status(&self) -> DeactivateUserStatus {
match self {
Self::Deactivated(_) => DeactivateUserStatus::Deactivated,
Self::IncorrectPassword => DeactivateUserStatus::IncorrectPassword,
}
}

async fn user(&self) -> Option<User> {
match self {
Self::Deactivated(user) => Some(User(user.clone())),
Self::IncorrectPassword => None,
}
}
}

fn valid_username_character(c: char) -> bool {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
Expand Down Expand Up @@ -868,4 +917,64 @@ impl UserMutations {
recovery_session_id: recovery_session.id,
})
}

/// Deactivate the current user account
///
/// If the user has a password, it *must* be supplied in the `password`
/// field.
async fn deactivate_user(
&self,
ctx: &Context<'_>,
input: DeactivateUserInput,
) -> Result<DeactivateUserPayload, async_graphql::Error> {
let state = ctx.state();
let mut rng = state.rng();
let clock = state.clock();
let requester = ctx.requester();
let site_config = state.site_config();

// Only allow calling this if the requester is a browser session
let Some(browser_session) = requester.browser_session() else {
return Err(async_graphql::Error::new("Unauthorized"));
};

if !site_config.account_deactivation_allowed {
return Err(async_graphql::Error::new(
"Account deactivation is not allowed on this server",
));
}

let mut repo = state.repository().await?;
if !verify_password_if_needed(
requester,
site_config,
&state.password_manager(),
input.password,
&browser_session.user,
&mut repo,
)
.await?
{
return Ok(DeactivateUserPayload::IncorrectPassword);
}

// Deactivate the user right away
let user = repo
.user()
.deactivate(&state.clock(), browser_session.user.clone())
.await?;

// and then schedule a job to deactivate it fully
repo.queue_job()
.schedule_job(
&mut rng,
&clock,
DeactivateUserJob::new(&user, input.hs_erase),
)
.await?;

repo.save().await?;

Ok(DeactivateUserPayload::Deactivated(user))
}
}
66 changes: 5 additions & 61 deletions crates/handlers/src/graphql/mutations/user_email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,75 +6,19 @@

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::{
BoxRepository, RepositoryAccess,
RepositoryAccess,
queue::{ProvisionUserJob, QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob},
user::{UserEmailFilter, UserEmailRepository, UserRepository},
};
use zeroize::Zeroizing;

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

/// 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
1 change: 1 addition & 0 deletions crates/handlers/src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ pub fn test_site_config() -> SiteConfig {
displayname_change_allowed: true,
password_change_allowed: true,
account_recovery_allowed: true,
account_deactivation_allowed: true,
captcha: None,
minimum_password_complexity: 1,
session_expiration: None,
Expand Down
4 changes: 4 additions & 0 deletions docs/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2463,6 +2463,10 @@
"password_recovery_enabled": {
"description": "Whether email-based password recovery is enabled. Defaults to `false`.\n\nThis has no effect if password login is disabled.",
"type": "boolean"
},
"account_deactivation_allowed": {
"description": "Whether users are allowed to delete their own account. Defaults to `false`.",
"type": "boolean"
}
}
},
Expand Down
5 changes: 5 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ account:
# Defaults to `false`.
# This has no effect if password login is disabled.
password_recovery_enabled: false

# Whether users are allowed to delete their own account
#
# Defaults to `false`.
account_deactivation_allowed: false
```

## `captcha`
Expand Down
12 changes: 12 additions & 0 deletions frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@
"account": {
"account_password": "Account password",
"contact_info": "Contact info",
"delete_account": {
"alert_description": "This account will be permanently erased and you’ll no longer have access to any of your messages.",
"alert_title": "You’re about to lose all of your data",
"button": "Delete account",
"dialog_description": "<text>Confirm that you would like to delete your account:</text>\n<profile />\n<list>\n<item>You will not be able to reactivate your account</item>\n<item>You will no longer be able to sign in</item>\n<item>No one will be able to reuse your username (MXID), including you</item>\n<item>You will leave all rooms and direct messages you are in</item>\n<item>You will be removed from the identity server, and no one will be able to find you with your email or phone number</item>\n</list>\n<text>Your old messages will still be visible to people who received them. Would you like to hide your send messages from people who join rooms in the future?</text>",
"dialog_title": "Delete this account?",
"erase_checkbox_label": "Yes, hide all my messages from new joiners",
"incorrect_password": "Incorrect password, please try again",
"mxid_label": "Confirm your Matrix ID ({{ mxid }})",
"mxid_mismatch": "This value does not match your Matrix ID",
"password_label": "Enter your password to continue"
},
"edit_profile": {
"display_name_help": "This is what others will see wherever you’re signed in.",
"display_name_label": "Display name",
Expand Down
Loading
Loading