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

Commit d76b54b

Browse files
authored
Add a setPassword GraphQL mutation for setting a user's password (#2820)
* Feed `PasswordManager` through to the GraphQL `State` * Add `setPassword` GraphQL mutation to update a user's password
1 parent fa0dec7 commit d76b54b

File tree

8 files changed

+370
-2
lines changed

8 files changed

+370
-2
lines changed

crates/cli/src/commands/server.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ impl Options {
200200
&policy_factory,
201201
homeserver_connection.clone(),
202202
site_config.clone(),
203+
password_manager.clone(),
203204
);
204205

205206
let state = {

crates/handlers/src/graphql/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ use self::{
5959
mutations::Mutation,
6060
query::Query,
6161
};
62-
use crate::{impl_from_error_for_route, BoundActivityTracker};
62+
use crate::{impl_from_error_for_route, passwords::PasswordManager, BoundActivityTracker};
6363

6464
#[cfg(test)]
6565
mod tests;
@@ -69,6 +69,7 @@ struct GraphQLState {
6969
homeserver_connection: Arc<dyn HomeserverConnection<Error = anyhow::Error>>,
7070
policy_factory: Arc<PolicyFactory>,
7171
site_config: SiteConfig,
72+
password_manager: PasswordManager,
7273
}
7374

7475
#[async_trait]
@@ -85,6 +86,10 @@ impl state::State for GraphQLState {
8586
self.policy_factory.instantiate().await
8687
}
8788

89+
fn password_manager(&self) -> PasswordManager {
90+
self.password_manager.clone()
91+
}
92+
8893
fn site_config(&self) -> &SiteConfig {
8994
&self.site_config
9095
}
@@ -113,12 +118,14 @@ pub fn schema(
113118
policy_factory: &Arc<PolicyFactory>,
114119
homeserver_connection: impl HomeserverConnection<Error = anyhow::Error> + 'static,
115120
site_config: SiteConfig,
121+
password_manager: PasswordManager,
116122
) -> Schema {
117123
let state = GraphQLState {
118124
pool: pool.clone(),
119125
policy_factory: Arc::clone(policy_factory),
120126
homeserver_connection: Arc::new(homeserver_connection),
121127
site_config,
128+
password_manager,
122129
};
123130
let state: BoxState = Box::new(state);
124131

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

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use mas_storage::{
1919
user::UserRepository,
2020
};
2121
use tracing::{info, warn};
22+
use zeroize::Zeroizing;
2223

2324
use crate::graphql::{
2425
model::{NodeType, User},
@@ -199,6 +200,66 @@ impl AllowUserCrossSigningResetPayload {
199200
}
200201
}
201202

203+
/// The input for the `setPassword` mutation.
204+
#[derive(InputObject)]
205+
struct SetPasswordInput {
206+
/// The ID of the user to set the password for.
207+
/// If you are not a server administrator then this must be your own user
208+
/// ID.
209+
user_id: ID,
210+
211+
/// The current password of the user.
212+
/// Required if you are not a server administrator.
213+
current_password: Option<String>,
214+
215+
/// The new password for the user.
216+
new_password: String,
217+
}
218+
219+
/// The return type for the `setPassword` mutation.
220+
#[derive(Description)]
221+
struct SetPasswordPayload {
222+
status: SetPasswordStatus,
223+
}
224+
225+
/// The status of the `setPassword` mutation.
226+
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
227+
enum SetPasswordStatus {
228+
/// The password was updated.
229+
Allowed,
230+
231+
/// The user was not found.
232+
NotFound,
233+
234+
/// The user doesn't have a current password to attempt to match against.
235+
NoCurrentPassword,
236+
237+
/// The supplied current password was wrong.
238+
WrongPassword,
239+
240+
/// The new password is invalid. For example, it may not meet configured
241+
/// security requirements.
242+
InvalidNewPassword,
243+
244+
/// You aren't allowed to set the password for that user.
245+
/// This happens if you aren't setting your own password and you aren't a
246+
/// server administrator.
247+
NotAllowed,
248+
249+
/// Password support has been disabled.
250+
/// This usually means that login is handled by an upstream identity
251+
/// provider.
252+
PasswordChangesDisabled,
253+
}
254+
255+
#[Object(use_type_description)]
256+
impl SetPasswordPayload {
257+
/// Status of the operation
258+
async fn status(&self) -> SetPasswordStatus {
259+
self.status
260+
}
261+
}
262+
202263
fn valid_username_character(c: char) -> bool {
203264
c.is_ascii_lowercase()
204265
|| c.is_ascii_digit()
@@ -385,4 +446,108 @@ impl UserMutations {
385446

386447
Ok(AllowUserCrossSigningResetPayload::Allowed(user))
387448
}
449+
450+
/// Set the password for a user.
451+
///
452+
/// This can be used by server administrators to set any user's password,
453+
/// or, provided the capability hasn't been disabled on this server,
454+
/// by a user to change their own password as long as they know their
455+
/// current password.
456+
async fn set_password(
457+
&self,
458+
ctx: &Context<'_>,
459+
input: SetPasswordInput,
460+
) -> Result<SetPasswordPayload, async_graphql::Error> {
461+
let state = ctx.state();
462+
let user_id = NodeType::User.extract_ulid(&input.user_id)?;
463+
let requester = ctx.requester();
464+
465+
if !requester.is_owner_or_admin(&UserId(user_id)) {
466+
return Err(async_graphql::Error::new("Unauthorized"));
467+
}
468+
469+
let mut policy = state.policy().await?;
470+
471+
let res = policy.evaluate_password(&input.new_password).await?;
472+
473+
if !res.valid() {
474+
// TODO Expose the reason for the policy violation
475+
// This involves redesigning the error handling
476+
// Idea would be to expose an errors array in the response,
477+
// with a list of union of different error kinds.
478+
return Ok(SetPasswordPayload {
479+
status: SetPasswordStatus::InvalidNewPassword,
480+
});
481+
}
482+
483+
let mut repo = state.repository().await?;
484+
let Some(user) = repo.user().lookup(user_id).await? else {
485+
return Ok(SetPasswordPayload {
486+
status: SetPasswordStatus::NotFound,
487+
});
488+
};
489+
490+
let password_manager = state.password_manager();
491+
if !requester.is_admin() {
492+
// If the user isn't an admin, we:
493+
// - check that password changes are enabled
494+
// - check that they know their current password
495+
496+
if !state.site_config().password_change_allowed || !password_manager.is_enabled() {
497+
return Ok(SetPasswordPayload {
498+
status: SetPasswordStatus::PasswordChangesDisabled,
499+
});
500+
}
501+
502+
let Some(active_password) = repo.user_password().active(&user).await? else {
503+
// The user has no current password, so can't verify against one.
504+
// In the future, it may be desirable to let the user set a password without any
505+
// other verification instead.
506+
507+
return Ok(SetPasswordPayload {
508+
status: SetPasswordStatus::NoCurrentPassword,
509+
});
510+
};
511+
512+
let Some(current_password_attempt) = input.current_password else {
513+
return Err(async_graphql::Error::new(
514+
"You must supply `currentPassword` to change your own password if you are not an administrator"
515+
));
516+
};
517+
518+
if let Err(_err) = password_manager
519+
.verify(
520+
active_password.version,
521+
Zeroizing::new(current_password_attempt.into_bytes()),
522+
active_password.hashed_password,
523+
)
524+
.await
525+
{
526+
return Ok(SetPasswordPayload {
527+
status: SetPasswordStatus::WrongPassword,
528+
});
529+
}
530+
}
531+
532+
let (new_password_version, new_password_hash) = password_manager
533+
.hash(state.rng(), Zeroizing::new(input.new_password.into_bytes()))
534+
.await?;
535+
536+
repo.user_password()
537+
.add(
538+
&mut state.rng(),
539+
&state.clock(),
540+
&user,
541+
new_password_version,
542+
new_password_hash,
543+
None,
544+
)
545+
.await?;
546+
547+
repo.save().await?;
548+
549+
Ok(SetPasswordPayload {
550+
status: SetPasswordStatus::Allowed,
551+
})
552+
}
388553
}

crates/handlers/src/graphql/state.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@ use mas_matrix::HomeserverConnection;
1717
use mas_policy::Policy;
1818
use mas_storage::{BoxClock, BoxRepository, BoxRng, RepositoryError};
1919

20-
use crate::graphql::Requester;
20+
use crate::{graphql::Requester, passwords::PasswordManager};
2121

2222
#[async_trait::async_trait]
2323
pub trait State {
2424
async fn repository(&self) -> Result<BoxRepository, RepositoryError>;
2525
async fn policy(&self) -> Result<Policy, mas_policy::InstantiateError>;
26+
fn password_manager(&self) -> PasswordManager;
2627
fn homeserver_connection(&self) -> &dyn HomeserverConnection<Error = anyhow::Error>;
2728
fn clock(&self) -> BoxClock;
2829
fn rng(&self) -> BoxRng;

crates/handlers/src/test_utils.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ impl TestState {
198198
site_config: site_config.clone(),
199199
rng: Arc::clone(&rng),
200200
clock: Arc::clone(&clock),
201+
password_manager: password_manager.clone(),
201202
};
202203
let state: crate::graphql::BoxState = Box::new(graphql_state);
203204

@@ -314,6 +315,7 @@ struct TestGraphQLState {
314315
policy_factory: Arc<PolicyFactory>,
315316
clock: Arc<MockClock>,
316317
rng: Arc<Mutex<ChaChaRng>>,
318+
password_manager: PasswordManager,
317319
}
318320

319321
#[async_trait]
@@ -332,6 +334,10 @@ impl graphql::State for TestGraphQLState {
332334
self.policy_factory.instantiate().await
333335
}
334336

337+
fn password_manager(&self) -> PasswordManager {
338+
self.password_manager.clone()
339+
}
340+
335341
fn homeserver_connection(&self) -> &dyn HomeserverConnection<Error = anyhow::Error> {
336342
&self.homeserver_connection
337343
}

frontend/schema.graphql

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,15 @@ type Mutation {
748748
input: AllowUserCrossSigningResetInput!
749749
): AllowUserCrossSigningResetPayload!
750750
"""
751+
Set the password for a user.
752+
753+
This can be used by server administrators to set any user's password,
754+
or, provided the capability hasn't been disabled on this server,
755+
by a user to change their own password as long as they know their
756+
current password.
757+
"""
758+
setPassword(input: SetPasswordInput!): SetPasswordPayload!
759+
"""
751760
Create a new arbitrary OAuth 2.0 Session.
752761
753762
Only available for administrators.
@@ -1205,6 +1214,76 @@ enum SetDisplayNameStatus {
12051214
INVALID
12061215
}
12071216

1217+
"""
1218+
The input for the `setPassword` mutation.
1219+
"""
1220+
input SetPasswordInput {
1221+
"""
1222+
The ID of the user to set the password for.
1223+
If you are not a server administrator then this must be your own user
1224+
ID.
1225+
"""
1226+
userId: ID!
1227+
"""
1228+
The current password of the user.
1229+
Required if you are not a server administrator.
1230+
"""
1231+
currentPassword: String
1232+
"""
1233+
The new password for the user.
1234+
"""
1235+
newPassword: String!
1236+
}
1237+
1238+
"""
1239+
The return type for the `setPassword` mutation.
1240+
"""
1241+
type SetPasswordPayload {
1242+
"""
1243+
Status of the operation
1244+
"""
1245+
status: SetPasswordStatus!
1246+
}
1247+
1248+
"""
1249+
The status of the `setPassword` mutation.
1250+
"""
1251+
enum SetPasswordStatus {
1252+
"""
1253+
The password was updated.
1254+
"""
1255+
ALLOWED
1256+
"""
1257+
The user was not found.
1258+
"""
1259+
NOT_FOUND
1260+
"""
1261+
The user doesn't have a current password to attempt to match against.
1262+
"""
1263+
NO_CURRENT_PASSWORD
1264+
"""
1265+
The supplied current password was wrong.
1266+
"""
1267+
WRONG_PASSWORD
1268+
"""
1269+
The new password is invalid. For example, it may not meet configured
1270+
security requirements.
1271+
"""
1272+
INVALID_NEW_PASSWORD
1273+
"""
1274+
You aren't allowed to set the password for that user.
1275+
This happens if you aren't setting your own password and you aren't a
1276+
server administrator.
1277+
"""
1278+
NOT_ALLOWED
1279+
"""
1280+
Password support has been disabled.
1281+
This usually means that login is handled by an upstream identity
1282+
provider.
1283+
"""
1284+
PASSWORD_CHANGES_DISABLED
1285+
}
1286+
12081287
"""
12091288
The input for the `setPrimaryEmail` mutation
12101289
"""

0 commit comments

Comments
 (0)