1use anyhow::Context as _;
8use async_graphql::{Context, Description, Enum, ID, InputObject, Object};
9use mas_storage::{
10 queue::{
11 DeactivateUserJob, ProvisionUserJob, QueueJobRepositoryExt as _,
12 SendAccountRecoveryEmailsJob,
13 },
14 user::UserRepository,
15};
16use tracing::{info, warn};
17use ulid::Ulid;
18use url::Url;
19use zeroize::Zeroizing;
20
21use super::verify_password_if_needed;
22use crate::graphql::{
23 UserId,
24 model::{NodeType, User},
25 state::ContextExt,
26};
27
28#[derive(Default)]
29pub struct UserMutations {
30 _private: (),
31}
32
33#[derive(InputObject)]
35struct AddUserInput {
36 username: String,
38
39 skip_homeserver_check: Option<bool>,
45}
46
47#[derive(Enum, Copy, Clone, Eq, PartialEq)]
49enum AddUserStatus {
50 Added,
52
53 Exists,
55
56 Reserved,
58
59 Invalid,
61}
62
63#[derive(Description)]
65enum AddUserPayload {
66 Added(mas_data_model::User),
67 Exists(mas_data_model::User),
68 Reserved,
69 Invalid,
70}
71
72#[Object(use_type_description)]
73impl AddUserPayload {
74 async fn status(&self) -> AddUserStatus {
76 match self {
77 Self::Added(_) => AddUserStatus::Added,
78 Self::Exists(_) => AddUserStatus::Exists,
79 Self::Reserved => AddUserStatus::Reserved,
80 Self::Invalid => AddUserStatus::Invalid,
81 }
82 }
83
84 async fn user(&self) -> Option<User> {
86 match self {
87 Self::Added(user) | Self::Exists(user) => Some(User(user.clone())),
88 Self::Invalid | Self::Reserved => None,
89 }
90 }
91}
92
93#[derive(InputObject)]
95struct LockUserInput {
96 user_id: ID,
98
99 deactivate: Option<bool>,
101}
102
103#[derive(Enum, Copy, Clone, Eq, PartialEq)]
105enum LockUserStatus {
106 Locked,
108
109 NotFound,
111}
112
113#[derive(Description)]
115enum LockUserPayload {
116 Locked(mas_data_model::User),
118
119 NotFound,
121}
122
123#[Object(use_type_description)]
124impl LockUserPayload {
125 async fn status(&self) -> LockUserStatus {
127 match self {
128 Self::Locked(_) => LockUserStatus::Locked,
129 Self::NotFound => LockUserStatus::NotFound,
130 }
131 }
132
133 async fn user(&self) -> Option<User> {
135 match self {
136 Self::Locked(user) => Some(User(user.clone())),
137 Self::NotFound => None,
138 }
139 }
140}
141
142#[derive(InputObject)]
144struct UnlockUserInput {
145 user_id: ID,
147}
148
149#[derive(Enum, Copy, Clone, Eq, PartialEq)]
151enum UnlockUserStatus {
152 Unlocked,
154
155 NotFound,
157}
158
159#[derive(Description)]
161enum UnlockUserPayload {
162 Unlocked(mas_data_model::User),
164
165 NotFound,
167}
168
169#[Object(use_type_description)]
170impl UnlockUserPayload {
171 async fn status(&self) -> UnlockUserStatus {
173 match self {
174 Self::Unlocked(_) => UnlockUserStatus::Unlocked,
175 Self::NotFound => UnlockUserStatus::NotFound,
176 }
177 }
178
179 async fn user(&self) -> Option<User> {
181 match self {
182 Self::Unlocked(user) => Some(User(user.clone())),
183 Self::NotFound => None,
184 }
185 }
186}
187
188#[derive(InputObject)]
190struct SetCanRequestAdminInput {
191 user_id: ID,
193
194 can_request_admin: bool,
196}
197
198#[derive(Description)]
200enum SetCanRequestAdminPayload {
201 Updated(mas_data_model::User),
203
204 NotFound,
206}
207
208#[Object(use_type_description)]
209impl SetCanRequestAdminPayload {
210 async fn user(&self) -> Option<User> {
212 match self {
213 Self::Updated(user) => Some(User(user.clone())),
214 Self::NotFound => None,
215 }
216 }
217}
218
219#[derive(InputObject)]
221struct AllowUserCrossSigningResetInput {
222 user_id: ID,
224}
225
226#[derive(Description)]
228enum AllowUserCrossSigningResetPayload {
229 Allowed(mas_data_model::User),
231
232 NotFound,
234}
235
236#[Object(use_type_description)]
237impl AllowUserCrossSigningResetPayload {
238 async fn user(&self) -> Option<User> {
240 match self {
241 Self::Allowed(user) => Some(User(user.clone())),
242 Self::NotFound => None,
243 }
244 }
245}
246
247#[derive(InputObject)]
249struct SetPasswordInput {
250 user_id: ID,
254
255 current_password: Option<String>,
258
259 new_password: String,
261}
262
263#[derive(InputObject)]
265struct SetPasswordByRecoveryInput {
266 ticket: String,
270
271 new_password: String,
273}
274
275#[derive(Description)]
277struct SetPasswordPayload {
278 status: SetPasswordStatus,
279}
280
281#[derive(Enum, Copy, Clone, Eq, PartialEq)]
283enum SetPasswordStatus {
284 Allowed,
286
287 NotFound,
289
290 NoCurrentPassword,
292
293 WrongPassword,
295
296 InvalidNewPassword,
299
300 NotAllowed,
304
305 PasswordChangesDisabled,
309
310 NoSuchRecoveryTicket,
312
313 RecoveryTicketAlreadyUsed,
316
317 ExpiredRecoveryTicket,
319
320 AccountLocked,
322}
323
324#[Object(use_type_description)]
325impl SetPasswordPayload {
326 async fn status(&self) -> SetPasswordStatus {
328 self.status
329 }
330}
331
332#[derive(InputObject)]
334pub struct ResendRecoveryEmailInput {
335 ticket: String,
337}
338
339#[derive(Description)]
341pub enum ResendRecoveryEmailPayload {
342 NoSuchRecoveryTicket,
343 RateLimited,
344 Sent { recovery_session_id: Ulid },
345}
346
347#[derive(Enum, Copy, Clone, Eq, PartialEq)]
349pub enum ResendRecoveryEmailStatus {
350 NoSuchRecoveryTicket,
352
353 RateLimited,
355
356 Sent,
358}
359
360#[Object(use_type_description)]
361impl ResendRecoveryEmailPayload {
362 async fn status(&self) -> ResendRecoveryEmailStatus {
364 match self {
365 Self::NoSuchRecoveryTicket => ResendRecoveryEmailStatus::NoSuchRecoveryTicket,
366 Self::RateLimited => ResendRecoveryEmailStatus::RateLimited,
367 Self::Sent { .. } => ResendRecoveryEmailStatus::Sent,
368 }
369 }
370
371 async fn progress_url(&self, context: &Context<'_>) -> Option<Url> {
373 let state = context.state();
374 let url_builder = state.url_builder();
375 match self {
376 Self::NoSuchRecoveryTicket | Self::RateLimited => None,
377 Self::Sent {
378 recovery_session_id,
379 } => {
380 let route = mas_router::AccountRecoveryProgress::new(*recovery_session_id);
381 Some(url_builder.absolute_url_for(&route))
382 }
383 }
384 }
385}
386
387#[derive(InputObject)]
389pub struct DeactivateUserInput {
390 hs_erase: bool,
399
400 password: Option<String>,
402}
403
404#[derive(Description)]
406pub enum DeactivateUserPayload {
407 Deactivated(mas_data_model::User),
409
410 IncorrectPassword,
412}
413
414#[derive(Enum, Copy, Clone, Eq, PartialEq)]
416pub enum DeactivateUserStatus {
417 Deactivated,
419
420 IncorrectPassword,
422}
423
424#[Object(use_type_description)]
425impl DeactivateUserPayload {
426 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
442fn valid_username_character(c: char) -> bool {
443 c.is_ascii_lowercase()
444 || c.is_ascii_digit()
445 || c == '='
446 || c == '_'
447 || c == '-'
448 || c == '.'
449 || c == '/'
450 || c == '+'
451}
452
453fn username_valid(username: &str) -> bool {
455 if username.is_empty() || username.len() > 255 {
456 return false;
457 }
458
459 if username.starts_with('_') {
461 return false;
462 }
463
464 if !username.chars().all(valid_username_character) {
466 return false;
467 }
468
469 true
470}
471
472#[Object]
473impl UserMutations {
474 async fn add_user(
476 &self,
477 ctx: &Context<'_>,
478 input: AddUserInput,
479 ) -> Result<AddUserPayload, async_graphql::Error> {
480 let state = ctx.state();
481 let requester = ctx.requester();
482 let clock = state.clock();
483 let mut rng = state.rng();
484
485 if !requester.is_admin() {
486 return Err(async_graphql::Error::new("Unauthorized"));
487 }
488
489 let mut repo = state.repository().await?;
490
491 if let Some(user) = repo.user().find_by_username(&input.username).await? {
492 return Ok(AddUserPayload::Exists(user));
493 }
494
495 if !username_valid(&input.username) {
497 return Ok(AddUserPayload::Invalid);
498 }
499
500 let homeserver_available = state
502 .homeserver_connection()
503 .is_localpart_available(&input.username)
504 .await?;
505
506 if !homeserver_available {
507 if !input.skip_homeserver_check.unwrap_or(false) {
508 return Ok(AddUserPayload::Reserved);
509 }
510
511 warn!("Skipped homeserver check for username {}", input.username);
513 }
514
515 let user = repo.user().add(&mut rng, &clock, input.username).await?;
516
517 repo.queue_job()
518 .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user))
519 .await?;
520
521 repo.save().await?;
522
523 Ok(AddUserPayload::Added(user))
524 }
525
526 async fn lock_user(
528 &self,
529 ctx: &Context<'_>,
530 input: LockUserInput,
531 ) -> Result<LockUserPayload, async_graphql::Error> {
532 let state = ctx.state();
533 let clock = state.clock();
534 let mut rng = state.rng();
535 let requester = ctx.requester();
536
537 if !requester.is_admin() {
538 return Err(async_graphql::Error::new("Unauthorized"));
539 }
540
541 let mut repo = state.repository().await?;
542
543 let user_id = NodeType::User.extract_ulid(&input.user_id)?;
544 let user = repo.user().lookup(user_id).await?;
545
546 let Some(user) = user else {
547 return Ok(LockUserPayload::NotFound);
548 };
549
550 let deactivate = input.deactivate.unwrap_or(false);
551
552 let user = repo.user().lock(&state.clock(), user).await?;
553
554 if deactivate {
555 info!("Scheduling deactivation of user {}", user.id);
556 repo.queue_job()
557 .schedule_job(&mut rng, &clock, DeactivateUserJob::new(&user, deactivate))
558 .await?;
559 }
560
561 repo.save().await?;
562
563 Ok(LockUserPayload::Locked(user))
564 }
565
566 async fn unlock_user(
568 &self,
569 ctx: &Context<'_>,
570 input: UnlockUserInput,
571 ) -> Result<UnlockUserPayload, async_graphql::Error> {
572 let state = ctx.state();
573 let requester = ctx.requester();
574 let matrix = state.homeserver_connection();
575
576 if !requester.is_admin() {
577 return Err(async_graphql::Error::new("Unauthorized"));
578 }
579
580 let mut repo = state.repository().await?;
581 let user_id = NodeType::User.extract_ulid(&input.user_id)?;
582 let user = repo.user().lookup(user_id).await?;
583
584 let Some(user) = user else {
585 return Ok(UnlockUserPayload::NotFound);
586 };
587
588 let mxid = matrix.mxid(&user.username);
590 matrix.reactivate_user(&mxid).await?;
591
592 let user = repo.user().unlock(user).await?;
594
595 repo.save().await?;
596
597 Ok(UnlockUserPayload::Unlocked(user))
598 }
599
600 async fn set_can_request_admin(
603 &self,
604 ctx: &Context<'_>,
605 input: SetCanRequestAdminInput,
606 ) -> Result<SetCanRequestAdminPayload, async_graphql::Error> {
607 let state = ctx.state();
608 let requester = ctx.requester();
609
610 if !requester.is_admin() {
611 return Err(async_graphql::Error::new("Unauthorized"));
612 }
613
614 let mut repo = state.repository().await?;
615
616 let user_id = NodeType::User.extract_ulid(&input.user_id)?;
617 let user = repo.user().lookup(user_id).await?;
618
619 let Some(user) = user else {
620 return Ok(SetCanRequestAdminPayload::NotFound);
621 };
622
623 let user = repo
624 .user()
625 .set_can_request_admin(user, input.can_request_admin)
626 .await?;
627
628 repo.save().await?;
629
630 Ok(SetCanRequestAdminPayload::Updated(user))
631 }
632
633 async fn allow_user_cross_signing_reset(
635 &self,
636 ctx: &Context<'_>,
637 input: AllowUserCrossSigningResetInput,
638 ) -> Result<AllowUserCrossSigningResetPayload, async_graphql::Error> {
639 let state = ctx.state();
640 let user_id = NodeType::User.extract_ulid(&input.user_id)?;
641 let requester = ctx.requester();
642
643 if !requester.is_owner_or_admin(&UserId(user_id)) {
644 return Err(async_graphql::Error::new("Unauthorized"));
645 }
646
647 let mut repo = state.repository().await?;
648 let user = repo.user().lookup(user_id).await?;
649 repo.cancel().await?;
650
651 let Some(user) = user else {
652 return Ok(AllowUserCrossSigningResetPayload::NotFound);
653 };
654
655 let conn = state.homeserver_connection();
656 let mxid = conn.mxid(&user.username);
657
658 conn.allow_cross_signing_reset(&mxid)
659 .await
660 .context("Failed to allow cross-signing reset")?;
661
662 Ok(AllowUserCrossSigningResetPayload::Allowed(user))
663 }
664
665 async fn set_password(
672 &self,
673 ctx: &Context<'_>,
674 input: SetPasswordInput,
675 ) -> Result<SetPasswordPayload, async_graphql::Error> {
676 let state = ctx.state();
677 let user_id = NodeType::User.extract_ulid(&input.user_id)?;
678 let requester = ctx.requester();
679
680 if !requester.is_owner_or_admin(&UserId(user_id)) {
681 return Err(async_graphql::Error::new("Unauthorized"));
682 }
683
684 if input.new_password.is_empty() {
685 return Ok(SetPasswordPayload {
690 status: SetPasswordStatus::InvalidNewPassword,
691 });
692 }
693
694 let password_manager = state.password_manager();
695
696 if !password_manager.is_enabled() {
697 return Ok(SetPasswordPayload {
698 status: SetPasswordStatus::PasswordChangesDisabled,
699 });
700 }
701
702 if !password_manager.is_password_complex_enough(&input.new_password)? {
703 return Ok(SetPasswordPayload {
704 status: SetPasswordStatus::InvalidNewPassword,
705 });
706 }
707
708 let mut repo = state.repository().await?;
709 let Some(user) = repo.user().lookup(user_id).await? else {
710 return Ok(SetPasswordPayload {
711 status: SetPasswordStatus::NotFound,
712 });
713 };
714
715 if !requester.is_admin() {
716 if !state.site_config().password_change_allowed {
721 return Ok(SetPasswordPayload {
722 status: SetPasswordStatus::PasswordChangesDisabled,
723 });
724 }
725
726 let Some(active_password) = repo.user_password().active(&user).await? else {
727 return Ok(SetPasswordPayload {
732 status: SetPasswordStatus::NoCurrentPassword,
733 });
734 };
735
736 let Some(current_password_attempt) = input.current_password else {
737 return Err(async_graphql::Error::new(
738 "You must supply `currentPassword` to change your own password if you are not an administrator",
739 ));
740 };
741
742 if let Err(_err) = password_manager
743 .verify(
744 active_password.version,
745 Zeroizing::new(current_password_attempt.into_bytes()),
746 active_password.hashed_password,
747 )
748 .await
749 {
750 return Ok(SetPasswordPayload {
751 status: SetPasswordStatus::WrongPassword,
752 });
753 }
754 }
755
756 let (new_password_version, new_password_hash) = password_manager
757 .hash(state.rng(), Zeroizing::new(input.new_password.into_bytes()))
758 .await?;
759
760 repo.user_password()
761 .add(
762 &mut state.rng(),
763 &state.clock(),
764 &user,
765 new_password_version,
766 new_password_hash,
767 None,
768 )
769 .await?;
770
771 repo.save().await?;
772
773 Ok(SetPasswordPayload {
774 status: SetPasswordStatus::Allowed,
775 })
776 }
777
778 async fn set_password_by_recovery(
780 &self,
781 ctx: &Context<'_>,
782 input: SetPasswordByRecoveryInput,
783 ) -> Result<SetPasswordPayload, async_graphql::Error> {
784 let state = ctx.state();
785 let requester = ctx.requester();
786 let clock = state.clock();
787 if !requester.is_unauthenticated() {
788 return Err(async_graphql::Error::new(
789 "Account recovery is only for anonymous users.",
790 ));
791 }
792
793 let password_manager = state.password_manager();
794
795 if !password_manager.is_enabled() || !state.site_config().account_recovery_allowed {
796 return Ok(SetPasswordPayload {
797 status: SetPasswordStatus::PasswordChangesDisabled,
798 });
799 }
800
801 if !password_manager.is_password_complex_enough(&input.new_password)? {
802 return Ok(SetPasswordPayload {
803 status: SetPasswordStatus::InvalidNewPassword,
804 });
805 }
806
807 let mut repo = state.repository().await?;
808
809 let Some(ticket) = repo.user_recovery().find_ticket(&input.ticket).await? else {
810 return Ok(SetPasswordPayload {
811 status: SetPasswordStatus::NoSuchRecoveryTicket,
812 });
813 };
814
815 let session = repo
816 .user_recovery()
817 .lookup_session(ticket.user_recovery_session_id)
818 .await?
819 .context("Unknown session")?;
820
821 if session.consumed_at.is_some() {
822 return Ok(SetPasswordPayload {
823 status: SetPasswordStatus::RecoveryTicketAlreadyUsed,
824 });
825 }
826
827 if !ticket.active(clock.now()) {
828 return Ok(SetPasswordPayload {
829 status: SetPasswordStatus::ExpiredRecoveryTicket,
830 });
831 }
832
833 let user_email = repo
834 .user_email()
835 .lookup(ticket.user_email_id)
836 .await?
837 .context("Unknown email address")?;
838
839 let user = repo
840 .user()
841 .lookup(user_email.user_id)
842 .await?
843 .context("Invalid user")?;
844
845 if !user.is_valid() {
846 return Ok(SetPasswordPayload {
847 status: SetPasswordStatus::AccountLocked,
848 });
849 }
850
851 let (new_password_version, new_password_hash) = password_manager
852 .hash(state.rng(), Zeroizing::new(input.new_password.into_bytes()))
853 .await?;
854
855 repo.user_password()
856 .add(
857 &mut state.rng(),
858 &state.clock(),
859 &user,
860 new_password_version,
861 new_password_hash,
862 None,
863 )
864 .await?;
865
866 repo.user_recovery()
868 .consume_ticket(&clock, ticket, session)
869 .await?;
870
871 repo.save().await?;
872
873 Ok(SetPasswordPayload {
874 status: SetPasswordStatus::Allowed,
875 })
876 }
877
878 pub async fn resend_recovery_email(
884 &self,
885 ctx: &Context<'_>,
886 input: ResendRecoveryEmailInput,
887 ) -> Result<ResendRecoveryEmailPayload, async_graphql::Error> {
888 let state = ctx.state();
889 let requester = ctx.requester();
890 let clock = state.clock();
891 let mut rng = state.rng();
892 let limiter = state.limiter();
893 let mut repo = state.repository().await?;
894
895 let Some(recovery_ticket) = repo.user_recovery().find_ticket(&input.ticket).await? else {
896 return Ok(ResendRecoveryEmailPayload::NoSuchRecoveryTicket);
897 };
898
899 let recovery_session = repo
900 .user_recovery()
901 .lookup_session(recovery_ticket.user_recovery_session_id)
902 .await?
903 .context("Could not load recovery session")?;
904
905 if let Err(e) =
906 limiter.check_account_recovery(requester.fingerprint(), &recovery_session.email)
907 {
908 tracing::warn!(error = &e as &dyn std::error::Error);
909 return Ok(ResendRecoveryEmailPayload::RateLimited);
910 }
911
912 repo.queue_job()
914 .schedule_job(
915 &mut rng,
916 &clock,
917 SendAccountRecoveryEmailsJob::new(&recovery_session),
918 )
919 .await?;
920
921 repo.save().await?;
922
923 Ok(ResendRecoveryEmailPayload::Sent {
924 recovery_session_id: recovery_session.id,
925 })
926 }
927
928 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 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 let user = repo
970 .user()
971 .deactivate(&state.clock(), browser_session.user.clone())
972 .await?;
973
974 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 }
987}