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 @@ -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
/// `true`.
#[serde(default = "default_true", skip_serializing_if = "is_default_true")]
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_true(),
}
}
}
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_true(&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())
}
116 changes: 116 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,61 @@ impl ResendRecoveryEmailPayload {
}
}

/// The input for the `deactivateUser` mutation.
#[derive(InputObject)]
pub struct DeactivateUserInput {
/// Whether to ask the homeserver to GDPR-erase the user
///
/// This is equivalent to the `erase` parameter on the
/// `/_matrix/client/v3/account/deactivate` C-S API, which is
/// implementation-specific.
///
/// What Synapse does is documented here:
/// <https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#deactivate-account>
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 +924,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 `true`.",
"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 `true`.
account_deactivation_allowed: true
```

## `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