From 79d457d47ca34e75cbf9fdbc54bac077e3f94b94 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Tue, 4 Jun 2024 18:07:32 +0100 Subject: [PATCH 1/2] Feed `PasswordManager` through to the GraphQL `State` --- crates/cli/src/commands/server.rs | 1 + crates/handlers/src/graphql/mod.rs | 9 ++++++++- crates/handlers/src/graphql/state.rs | 3 ++- crates/handlers/src/test_utils.rs | 6 ++++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 5563513e0..108278eeb 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -200,6 +200,7 @@ impl Options { &policy_factory, homeserver_connection.clone(), site_config.clone(), + password_manager.clone(), ); let state = { diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index 309af31d2..1ea288080 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -59,7 +59,7 @@ use self::{ mutations::Mutation, query::Query, }; -use crate::{impl_from_error_for_route, BoundActivityTracker}; +use crate::{impl_from_error_for_route, passwords::PasswordManager, BoundActivityTracker}; #[cfg(test)] mod tests; @@ -69,6 +69,7 @@ struct GraphQLState { homeserver_connection: Arc>, policy_factory: Arc, site_config: SiteConfig, + password_manager: PasswordManager, } #[async_trait] @@ -85,6 +86,10 @@ impl state::State for GraphQLState { self.policy_factory.instantiate().await } + fn password_manager(&self) -> PasswordManager { + self.password_manager.clone() + } + fn site_config(&self) -> &SiteConfig { &self.site_config } @@ -113,12 +118,14 @@ pub fn schema( policy_factory: &Arc, homeserver_connection: impl HomeserverConnection + 'static, site_config: SiteConfig, + password_manager: PasswordManager, ) -> Schema { let state = GraphQLState { pool: pool.clone(), policy_factory: Arc::clone(policy_factory), homeserver_connection: Arc::new(homeserver_connection), site_config, + password_manager, }; let state: BoxState = Box::new(state); diff --git a/crates/handlers/src/graphql/state.rs b/crates/handlers/src/graphql/state.rs index 2caa76704..86797015c 100644 --- a/crates/handlers/src/graphql/state.rs +++ b/crates/handlers/src/graphql/state.rs @@ -17,12 +17,13 @@ use mas_matrix::HomeserverConnection; use mas_policy::Policy; use mas_storage::{BoxClock, BoxRepository, BoxRng, RepositoryError}; -use crate::graphql::Requester; +use crate::{graphql::Requester, passwords::PasswordManager}; #[async_trait::async_trait] pub trait State { async fn repository(&self) -> Result; async fn policy(&self) -> Result; + fn password_manager(&self) -> PasswordManager; fn homeserver_connection(&self) -> &dyn HomeserverConnection; fn clock(&self) -> BoxClock; fn rng(&self) -> BoxRng; diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 5741c7ebf..b75c930ce 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -198,6 +198,7 @@ impl TestState { site_config: site_config.clone(), rng: Arc::clone(&rng), clock: Arc::clone(&clock), + password_manager: password_manager.clone(), }; let state: crate::graphql::BoxState = Box::new(graphql_state); @@ -314,6 +315,7 @@ struct TestGraphQLState { policy_factory: Arc, clock: Arc, rng: Arc>, + password_manager: PasswordManager, } #[async_trait] @@ -332,6 +334,10 @@ impl graphql::State for TestGraphQLState { self.policy_factory.instantiate().await } + fn password_manager(&self) -> PasswordManager { + self.password_manager.clone() + } + fn homeserver_connection(&self) -> &dyn HomeserverConnection { &self.homeserver_connection } From 20bde3205fe108157887069fee023258e1db5551 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Tue, 4 Jun 2024 18:09:11 +0100 Subject: [PATCH 2/2] Add `setPassword` GraphQL mutation to update a user's password --- crates/handlers/src/graphql/mutations/user.rs | 165 ++++++++++++++++++ frontend/schema.graphql | 79 +++++++++ frontend/src/gql/graphql.ts | 68 ++++++++ frontend/src/gql/schema.ts | 41 +++++ 4 files changed, 353 insertions(+) diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index c43e3741f..86bc35775 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -19,6 +19,7 @@ use mas_storage::{ user::UserRepository, }; use tracing::{info, warn}; +use zeroize::Zeroizing; use crate::graphql::{ model::{NodeType, User}, @@ -199,6 +200,66 @@ impl AllowUserCrossSigningResetPayload { } } +/// The input for the `setPassword` mutation. +#[derive(InputObject)] +struct SetPasswordInput { + /// The ID of the user to set the password for. + /// If you are not a server administrator then this must be your own user + /// ID. + user_id: ID, + + /// The current password of the user. + /// Required if you are not a server administrator. + current_password: Option, + + /// The new password for the user. + new_password: String, +} + +/// The return type for the `setPassword` mutation. +#[derive(Description)] +struct SetPasswordPayload { + status: SetPasswordStatus, +} + +/// The status of the `setPassword` mutation. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum SetPasswordStatus { + /// The password was updated. + Allowed, + + /// The user was not found. + NotFound, + + /// The user doesn't have a current password to attempt to match against. + NoCurrentPassword, + + /// The supplied current password was wrong. + WrongPassword, + + /// The new password is invalid. For example, it may not meet configured + /// security requirements. + InvalidNewPassword, + + /// You aren't allowed to set the password for that user. + /// This happens if you aren't setting your own password and you aren't a + /// server administrator. + NotAllowed, + + /// Password support has been disabled. + /// This usually means that login is handled by an upstream identity + /// provider. + PasswordChangesDisabled, +} + +#[Object(use_type_description)] +impl SetPasswordPayload { + /// Status of the operation + async fn status(&self) -> SetPasswordStatus { + self.status + } +} + fn valid_username_character(c: char) -> bool { c.is_ascii_lowercase() || c.is_ascii_digit() @@ -385,4 +446,108 @@ impl UserMutations { Ok(AllowUserCrossSigningResetPayload::Allowed(user)) } + + /// Set the password for a user. + /// + /// This can be used by server administrators to set any user's password, + /// or, provided the capability hasn't been disabled on this server, + /// by a user to change their own password as long as they know their + /// current password. + async fn set_password( + &self, + ctx: &Context<'_>, + input: SetPasswordInput, + ) -> Result { + let state = ctx.state(); + let user_id = NodeType::User.extract_ulid(&input.user_id)?; + let requester = ctx.requester(); + + if !requester.is_owner_or_admin(&UserId(user_id)) { + return Err(async_graphql::Error::new("Unauthorized")); + } + + let mut policy = state.policy().await?; + + let res = policy.evaluate_password(&input.new_password).await?; + + if !res.valid() { + // TODO Expose the reason for the policy violation + // This involves redesigning the error handling + // Idea would be to expose an errors array in the response, + // with a list of union of different error kinds. + return Ok(SetPasswordPayload { + status: SetPasswordStatus::InvalidNewPassword, + }); + } + + let mut repo = state.repository().await?; + let Some(user) = repo.user().lookup(user_id).await? else { + return Ok(SetPasswordPayload { + status: SetPasswordStatus::NotFound, + }); + }; + + let password_manager = state.password_manager(); + if !requester.is_admin() { + // If the user isn't an admin, we: + // - check that password changes are enabled + // - check that they know their current password + + if !state.site_config().password_change_allowed || !password_manager.is_enabled() { + return Ok(SetPasswordPayload { + status: SetPasswordStatus::PasswordChangesDisabled, + }); + } + + let Some(active_password) = repo.user_password().active(&user).await? else { + // The user has no current password, so can't verify against one. + // In the future, it may be desirable to let the user set a password without any + // other verification instead. + + return Ok(SetPasswordPayload { + status: SetPasswordStatus::NoCurrentPassword, + }); + }; + + let Some(current_password_attempt) = input.current_password else { + return Err(async_graphql::Error::new( + "You must supply `currentPassword` to change your own password if you are not an administrator" + )); + }; + + if let Err(_err) = password_manager + .verify( + active_password.version, + Zeroizing::new(current_password_attempt.into_bytes()), + active_password.hashed_password, + ) + .await + { + return Ok(SetPasswordPayload { + status: SetPasswordStatus::WrongPassword, + }); + } + } + + let (new_password_version, new_password_hash) = password_manager + .hash(state.rng(), Zeroizing::new(input.new_password.into_bytes())) + .await?; + + repo.user_password() + .add( + &mut state.rng(), + &state.clock(), + &user, + new_password_version, + new_password_hash, + None, + ) + .await?; + + repo.save().await?; + + Ok(SetPasswordPayload { + status: SetPasswordStatus::Allowed, + }) + } } diff --git a/frontend/schema.graphql b/frontend/schema.graphql index fae88aa09..a4b2b3b41 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -748,6 +748,15 @@ type Mutation { input: AllowUserCrossSigningResetInput! ): AllowUserCrossSigningResetPayload! """ + Set the password for a user. + + This can be used by server administrators to set any user's password, + or, provided the capability hasn't been disabled on this server, + by a user to change their own password as long as they know their + current password. + """ + setPassword(input: SetPasswordInput!): SetPasswordPayload! + """ Create a new arbitrary OAuth 2.0 Session. Only available for administrators. @@ -1205,6 +1214,76 @@ enum SetDisplayNameStatus { INVALID } +""" +The input for the `setPassword` mutation. +""" +input SetPasswordInput { + """ + The ID of the user to set the password for. + If you are not a server administrator then this must be your own user + ID. + """ + userId: ID! + """ + The current password of the user. + Required if you are not a server administrator. + """ + currentPassword: String + """ + The new password for the user. + """ + newPassword: String! +} + +""" +The return type for the `setPassword` mutation. +""" +type SetPasswordPayload { + """ + Status of the operation + """ + status: SetPasswordStatus! +} + +""" +The status of the `setPassword` mutation. +""" +enum SetPasswordStatus { + """ + The password was updated. + """ + ALLOWED + """ + The user was not found. + """ + NOT_FOUND + """ + The user doesn't have a current password to attempt to match against. + """ + NO_CURRENT_PASSWORD + """ + The supplied current password was wrong. + """ + WRONG_PASSWORD + """ + The new password is invalid. For example, it may not meet configured + security requirements. + """ + INVALID_NEW_PASSWORD + """ + You aren't allowed to set the password for that user. + This happens if you aren't setting your own password and you aren't a + server administrator. + """ + NOT_ALLOWED + """ + Password support has been disabled. + This usually means that login is handled by an upstream identity + provider. + """ + PASSWORD_CHANGES_DISABLED +} + """ The input for the `setPrimaryEmail` mutation """ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index e9a26cc81..c529fa5cf 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -486,6 +486,15 @@ export type Mutation = { setCanRequestAdmin: SetCanRequestAdminPayload; /** Set the display name of a user */ setDisplayName: SetDisplayNamePayload; + /** + * Set the password for a user. + * + * This can be used by server administrators to set any user's password, + * or, provided the capability hasn't been disabled on this server, + * by a user to change their own password as long as they know their + * current password. + */ + setPassword: SetPasswordPayload; /** Set an email address as primary */ setPrimaryEmail: SetPrimaryEmailPayload; /** Submit a verification code for an email address */ @@ -565,6 +574,12 @@ export type MutationSetDisplayNameArgs = { }; +/** The mutations root of the GraphQL interface. */ +export type MutationSetPasswordArgs = { + input: SetPasswordInput; +}; + + /** The mutations root of the GraphQL interface. */ export type MutationSetPrimaryEmailArgs = { input: SetPrimaryEmailInput; @@ -903,6 +918,59 @@ export enum SetDisplayNameStatus { Set = 'SET' } +/** The input for the `setPassword` mutation. */ +export type SetPasswordInput = { + /** + * The current password of the user. + * Required if you are not a server administrator. + */ + currentPassword?: InputMaybe; + /** The new password for the user. */ + newPassword: Scalars['String']['input']; + /** + * The ID of the user to set the password for. + * If you are not a server administrator then this must be your own user + * ID. + */ + userId: Scalars['ID']['input']; +}; + +/** The return type for the `setPassword` mutation. */ +export type SetPasswordPayload = { + __typename?: 'SetPasswordPayload'; + /** Status of the operation */ + status: SetPasswordStatus; +}; + +/** The status of the `setPassword` mutation. */ +export enum SetPasswordStatus { + /** The password was updated. */ + Allowed = 'ALLOWED', + /** + * The new password is invalid. For example, it may not meet configured + * security requirements. + */ + InvalidNewPassword = 'INVALID_NEW_PASSWORD', + /** + * You aren't allowed to set the password for that user. + * This happens if you aren't setting your own password and you aren't a + * server administrator. + */ + NotAllowed = 'NOT_ALLOWED', + /** The user was not found. */ + NotFound = 'NOT_FOUND', + /** The user doesn't have a current password to attempt to match against. */ + NoCurrentPassword = 'NO_CURRENT_PASSWORD', + /** + * Password support has been disabled. + * This usually means that login is handled by an upstream identity + * provider. + */ + PasswordChangesDisabled = 'PASSWORD_CHANGES_DISABLED', + /** The supplied current password was wrong. */ + WrongPassword = 'WRONG_PASSWORD' +} + /** The input for the `setPrimaryEmail` mutation */ export type SetPrimaryEmailInput = { /** The ID of the email address to set as primary */ diff --git a/frontend/src/gql/schema.ts b/frontend/src/gql/schema.ts index eb6498481..612c6918b 100644 --- a/frontend/src/gql/schema.ts +++ b/frontend/src/gql/schema.ts @@ -1422,6 +1422,29 @@ export default { } ] }, + { + "name": "setPassword", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "SetPasswordPayload", + "ofType": null + } + }, + "args": [ + { + "name": "input", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Any" + } + } + } + ] + }, { "name": "setPrimaryEmail", "type": { @@ -2386,6 +2409,24 @@ export default { ], "interfaces": [] }, + { + "kind": "OBJECT", + "name": "SetPasswordPayload", + "fields": [ + { + "name": "status", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Any" + } + }, + "args": [] + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "SetPrimaryEmailPayload",