Skip to content
This repository has been archived by the owner on Sep 10, 2024. It is now read-only.

Add a setPassword GraphQL mutation for setting a user's password #2820

Merged
merged 2 commits into from
Jun 5, 2024
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/commands/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ impl Options {
&policy_factory,
homeserver_connection.clone(),
site_config.clone(),
password_manager.clone(),
);

let state = {
Expand Down
9 changes: 8 additions & 1 deletion crates/handlers/src/graphql/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -69,6 +69,7 @@ struct GraphQLState {
homeserver_connection: Arc<dyn HomeserverConnection<Error = anyhow::Error>>,
policy_factory: Arc<PolicyFactory>,
site_config: SiteConfig,
password_manager: PasswordManager,
}

#[async_trait]
Expand All @@ -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
}
Expand Down Expand Up @@ -113,12 +118,14 @@ pub fn schema(
policy_factory: &Arc<PolicyFactory>,
homeserver_connection: impl HomeserverConnection<Error = anyhow::Error> + '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);

Expand Down
165 changes: 165 additions & 0 deletions crates/handlers/src/graphql/mutations/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use mas_storage::{
user::UserRepository,
};
use tracing::{info, warn};
use zeroize::Zeroizing;

use crate::graphql::{
model::{NodeType, User},
Expand Down Expand Up @@ -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<String>,

/// The new password for the user.
new_password: String,
}

/// The return type for the `setPassword` mutation.
#[derive(Description)]
struct SetPasswordPayload {
status: SetPasswordStatus,
sandhose marked this conversation as resolved.
Show resolved Hide resolved
}

/// 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()
Expand Down Expand Up @@ -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(
reivilibre marked this conversation as resolved.
Show resolved Hide resolved
&self,
ctx: &Context<'_>,
input: SetPasswordInput,
) -> Result<SetPasswordPayload, async_graphql::Error> {
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,
})
}
}
3 changes: 2 additions & 1 deletion crates/handlers/src/graphql/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BoxRepository, RepositoryError>;
async fn policy(&self) -> Result<Policy, mas_policy::InstantiateError>;
fn password_manager(&self) -> PasswordManager;
fn homeserver_connection(&self) -> &dyn HomeserverConnection<Error = anyhow::Error>;
fn clock(&self) -> BoxClock;
fn rng(&self) -> BoxRng;
Expand Down
6 changes: 6 additions & 0 deletions crates/handlers/src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -314,6 +315,7 @@ struct TestGraphQLState {
policy_factory: Arc<PolicyFactory>,
clock: Arc<MockClock>,
rng: Arc<Mutex<ChaChaRng>>,
password_manager: PasswordManager,
}

#[async_trait]
Expand All @@ -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<Error = anyhow::Error> {
&self.homeserver_connection
}
Expand Down
79 changes: 79 additions & 0 deletions frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
"""
Expand Down
Loading
Loading