Skip to content

Commit 9829c2c

Browse files
authored
Allow users to deactivate their own account in the UI (#4209)
2 parents 91f87d6 + 3543b40 commit 9829c2c

31 files changed

+1015
-237
lines changed

crates/cli/src/util.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ pub fn site_config_from_config(
207207
&& account_config.password_change_allowed,
208208
account_recovery_allowed: password_config.enabled()
209209
&& account_config.password_recovery_enabled,
210+
account_deactivation_allowed: account_config.account_deactivation_allowed,
210211
captcha,
211212
minimum_password_complexity: password_config.minimum_complexity(),
212213
session_expiration,

crates/config/src/sections/account.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ pub struct AccountConfig {
6161
/// This has no effect if password login is disabled.
6262
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
6363
pub password_recovery_enabled: bool,
64+
65+
/// Whether users are allowed to delete their own account. Defaults to
66+
/// `true`.
67+
#[serde(default = "default_true", skip_serializing_if = "is_default_true")]
68+
pub account_deactivation_allowed: bool,
6469
}
6570

6671
impl Default for AccountConfig {
@@ -71,6 +76,7 @@ impl Default for AccountConfig {
7176
password_registration_enabled: default_false(),
7277
password_change_allowed: default_true(),
7378
password_recovery_enabled: default_false(),
79+
account_deactivation_allowed: default_true(),
7480
}
7581
}
7682
}
@@ -83,6 +89,7 @@ impl AccountConfig {
8389
&& is_default_true(&self.displayname_change_allowed)
8490
&& is_default_true(&self.password_change_allowed)
8591
&& is_default_false(&self.password_recovery_enabled)
92+
&& is_default_true(&self.account_deactivation_allowed)
8693
}
8794
}
8895

crates/data-model/src/site_config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ pub struct SiteConfig {
7676
/// Whether users can recover their account via email.
7777
pub account_recovery_allowed: bool,
7878

79+
/// Whether users can delete their own account.
80+
pub account_deactivation_allowed: bool,
81+
7982
/// Captcha configuration
8083
pub captcha: Option<CaptchaConfig>,
8184

crates/handlers/src/graphql/model/site_config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ pub struct SiteConfig {
4646
/// Whether passwords are enabled and users can register using a password.
4747
password_registration_enabled: bool,
4848

49+
/// Whether users can delete their own account.
50+
account_deactivation_allowed: bool,
51+
4952
/// Minimum password complexity, from 0 to 4, in terms of a zxcvbn score.
5053
/// The exact scorer (including dictionaries and other data tables)
5154
/// in use is <https://crates.io/crates/zxcvbn>.
@@ -93,6 +96,7 @@ impl SiteConfig {
9396
password_login_enabled: data_model.password_login_enabled,
9497
password_change_allowed: data_model.password_change_allowed,
9598
password_registration_enabled: data_model.password_registration_enabled,
99+
account_deactivation_allowed: data_model.account_deactivation_allowed,
96100
minimum_password_complexity: data_model.minimum_password_complexity,
97101
}
98102
}

crates/handlers/src/graphql/mutations/mod.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@ mod oauth2_session;
1111
mod user;
1212
mod user_email;
1313

14+
use anyhow::Context as _;
1415
use async_graphql::MergedObject;
16+
use mas_data_model::SiteConfig;
17+
use mas_storage::BoxRepository;
18+
use zeroize::Zeroizing;
19+
20+
use super::Requester;
21+
use crate::passwords::PasswordManager;
1522

1623
/// The mutations root of the GraphQL interface.
1724
#[derive(Default, MergedObject)]
@@ -30,3 +37,54 @@ impl Mutation {
3037
Self::default()
3138
}
3239
}
40+
41+
/// Check the password if neeed
42+
///
43+
/// Returns true if password verification is not needed, or if the password is
44+
/// correct. Returns false if the password is incorrect or missing.
45+
async fn verify_password_if_needed(
46+
requester: &Requester,
47+
config: &SiteConfig,
48+
password_manager: &PasswordManager,
49+
password: Option<String>,
50+
user: &mas_data_model::User,
51+
repo: &mut BoxRepository,
52+
) -> Result<bool, async_graphql::Error> {
53+
// If the requester is admin, they don't need to provide a password
54+
if requester.is_admin() {
55+
return Ok(true);
56+
}
57+
58+
// If password login is disabled, assume we don't want the user to reauth
59+
if !config.password_login_enabled {
60+
return Ok(true);
61+
}
62+
63+
// Else we need to check if the user has a password
64+
let Some(user_password) = repo
65+
.user_password()
66+
.active(user)
67+
.await
68+
.context("Failed to load user password")?
69+
else {
70+
// User has no password, so we don't need to verify the password
71+
return Ok(true);
72+
};
73+
74+
let Some(password) = password else {
75+
// There is a password on the user, but not provided in the input
76+
return Ok(false);
77+
};
78+
79+
let password = Zeroizing::new(password.into_bytes());
80+
81+
let res = password_manager
82+
.verify(
83+
user_password.version,
84+
password,
85+
user_password.hashed_password,
86+
)
87+
.await;
88+
89+
Ok(res.is_ok())
90+
}

crates/handlers/src/graphql/mutations/user.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use ulid::Ulid;
1818
use url::Url;
1919
use zeroize::Zeroizing;
2020

21+
use super::verify_password_if_needed;
2122
use crate::graphql::{
2223
UserId,
2324
model::{NodeType, User},
@@ -383,6 +384,61 @@ impl ResendRecoveryEmailPayload {
383384
}
384385
}
385386

387+
/// The input for the `deactivateUser` mutation.
388+
#[derive(InputObject)]
389+
pub struct DeactivateUserInput {
390+
/// Whether to ask the homeserver to GDPR-erase the user
391+
///
392+
/// This is equivalent to the `erase` parameter on the
393+
/// `/_matrix/client/v3/account/deactivate` C-S API, which is
394+
/// implementation-specific.
395+
///
396+
/// What Synapse does is documented here:
397+
/// <https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#deactivate-account>
398+
hs_erase: bool,
399+
400+
/// The password of the user to deactivate.
401+
password: Option<String>,
402+
}
403+
404+
/// The payload for the `deactivateUser` mutation.
405+
#[derive(Description)]
406+
pub enum DeactivateUserPayload {
407+
/// The user was deactivated.
408+
Deactivated(mas_data_model::User),
409+
410+
/// The password was wrong or missing.
411+
IncorrectPassword,
412+
}
413+
414+
/// The status of the `deactivateUser` mutation.
415+
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
416+
pub enum DeactivateUserStatus {
417+
/// The user was deactivated.
418+
Deactivated,
419+
420+
/// The password was wrong.
421+
IncorrectPassword,
422+
}
423+
424+
#[Object(use_type_description)]
425+
impl DeactivateUserPayload {
426+
/// Status of the operation
427+
async fn status(&self) -> DeactivateUserStatus {
428+
match self {
429+
Self::Deactivated(_) => DeactivateUserStatus::Deactivated,
430+
Self::IncorrectPassword => DeactivateUserStatus::IncorrectPassword,
431+
}
432+
}
433+
434+
async fn user(&self) -> Option<User> {
435+
match self {
436+
Self::Deactivated(user) => Some(User(user.clone())),
437+
Self::IncorrectPassword => None,
438+
}
439+
}
440+
}
441+
386442
fn valid_username_character(c: char) -> bool {
387443
c.is_ascii_lowercase()
388444
|| c.is_ascii_digit()
@@ -868,4 +924,64 @@ impl UserMutations {
868924
recovery_session_id: recovery_session.id,
869925
})
870926
}
927+
928+
/// Deactivate the current user account
929+
///
930+
/// If the user has a password, it *must* be supplied in the `password`
931+
/// field.
932+
async fn deactivate_user(
933+
&self,
934+
ctx: &Context<'_>,
935+
input: DeactivateUserInput,
936+
) -> Result<DeactivateUserPayload, async_graphql::Error> {
937+
let state = ctx.state();
938+
let mut rng = state.rng();
939+
let clock = state.clock();
940+
let requester = ctx.requester();
941+
let site_config = state.site_config();
942+
943+
// Only allow calling this if the requester is a browser session
944+
let Some(browser_session) = requester.browser_session() else {
945+
return Err(async_graphql::Error::new("Unauthorized"));
946+
};
947+
948+
if !site_config.account_deactivation_allowed {
949+
return Err(async_graphql::Error::new(
950+
"Account deactivation is not allowed on this server",
951+
));
952+
}
953+
954+
let mut repo = state.repository().await?;
955+
if !verify_password_if_needed(
956+
requester,
957+
site_config,
958+
&state.password_manager(),
959+
input.password,
960+
&browser_session.user,
961+
&mut repo,
962+
)
963+
.await?
964+
{
965+
return Ok(DeactivateUserPayload::IncorrectPassword);
966+
}
967+
968+
// Deactivate the user right away
969+
let user = repo
970+
.user()
971+
.deactivate(&state.clock(), browser_session.user.clone())
972+
.await?;
973+
974+
// and then schedule a job to deactivate it fully
975+
repo.queue_job()
976+
.schedule_job(
977+
&mut rng,
978+
&clock,
979+
DeactivateUserJob::new(&user, input.hs_erase),
980+
)
981+
.await?;
982+
983+
repo.save().await?;
984+
985+
Ok(DeactivateUserPayload::Deactivated(user))
986+
}
871987
}

crates/handlers/src/graphql/mutations/user_email.rs

Lines changed: 5 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -6,75 +6,19 @@
66

77
use anyhow::Context as _;
88
use async_graphql::{Context, Description, Enum, ID, InputObject, Object};
9-
use mas_data_model::SiteConfig;
109
use mas_i18n::DataLocale;
1110
use mas_storage::{
12-
BoxRepository, RepositoryAccess,
11+
RepositoryAccess,
1312
queue::{ProvisionUserJob, QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob},
1413
user::{UserEmailFilter, UserEmailRepository, UserRepository},
1514
};
16-
use zeroize::Zeroizing;
1715

18-
use crate::{
19-
graphql::{
20-
Requester,
21-
model::{NodeType, User, UserEmail, UserEmailAuthentication},
22-
state::ContextExt,
23-
},
24-
passwords::PasswordManager,
16+
use super::verify_password_if_needed;
17+
use crate::graphql::{
18+
model::{NodeType, User, UserEmail, UserEmailAuthentication},
19+
state::ContextExt,
2520
};
2621

27-
/// Check the password if neeed
28-
///
29-
/// Returns true if password verification is not needed, or if the password is
30-
/// correct. Returns false if the password is incorrect or missing.
31-
async fn verify_password_if_needed(
32-
requester: &Requester,
33-
config: &SiteConfig,
34-
password_manager: &PasswordManager,
35-
password: Option<String>,
36-
user: &mas_data_model::User,
37-
repo: &mut BoxRepository,
38-
) -> Result<bool, async_graphql::Error> {
39-
// If the requester is admin, they don't need to provide a password
40-
if requester.is_admin() {
41-
return Ok(true);
42-
}
43-
44-
// If password login is disabled, assume we don't want the user to reauth
45-
if !config.password_login_enabled {
46-
return Ok(true);
47-
}
48-
49-
// Else we need to check if the user has a password
50-
let Some(user_password) = repo
51-
.user_password()
52-
.active(user)
53-
.await
54-
.context("Failed to load user password")?
55-
else {
56-
// User has no password, so we don't need to verify the password
57-
return Ok(true);
58-
};
59-
60-
let Some(password) = password else {
61-
// There is a password on the user, but not provided in the input
62-
return Ok(false);
63-
};
64-
65-
let password = Zeroizing::new(password.into_bytes());
66-
67-
let res = password_manager
68-
.verify(
69-
user_password.version,
70-
password,
71-
user_password.hashed_password,
72-
)
73-
.await;
74-
75-
Ok(res.is_ok())
76-
}
77-
7822
#[derive(Default)]
7923
pub struct UserEmailMutations {
8024
_private: (),

crates/handlers/src/test_utils.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ pub fn test_site_config() -> SiteConfig {
137137
displayname_change_allowed: true,
138138
password_change_allowed: true,
139139
account_recovery_allowed: true,
140+
account_deactivation_allowed: true,
140141
captcha: None,
141142
minimum_password_complexity: 1,
142143
session_expiration: None,

docs/config.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2463,6 +2463,10 @@
24632463
"password_recovery_enabled": {
24642464
"description": "Whether email-based password recovery is enabled. Defaults to `false`.\n\nThis has no effect if password login is disabled.",
24652465
"type": "boolean"
2466+
},
2467+
"account_deactivation_allowed": {
2468+
"description": "Whether users are allowed to delete their own account. Defaults to `true`.",
2469+
"type": "boolean"
24662470
}
24672471
}
24682472
},

docs/reference/configuration.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,11 @@ account:
309309
# Defaults to `false`.
310310
# This has no effect if password login is disabled.
311311
password_recovery_enabled: false
312+
313+
# Whether users are allowed to delete their own account
314+
#
315+
# Defaults to `true`.
316+
account_deactivation_allowed: true
312317
```
313318
314319
## `captcha`

0 commit comments

Comments
 (0)