1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-06 06:02:40 +03:00

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
This commit is contained in:
reivilibre
2024-06-05 18:04:17 +01:00
committed by GitHub
parent fa0dec737b
commit d76b54b13f
8 changed files with 370 additions and 2 deletions

View File

@@ -200,6 +200,7 @@ impl Options {
&policy_factory,
homeserver_connection.clone(),
site_config.clone(),
password_manager.clone(),
);
let state = {

View File

@@ -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<dyn HomeserverConnection<Error = anyhow::Error>>,
policy_factory: Arc<PolicyFactory>,
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<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);

View File

@@ -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<String>,
/// 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<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,
})
}
}

View File

@@ -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;

View File

@@ -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<PolicyFactory>,
clock: Arc<MockClock>,
rng: Arc<Mutex<ChaChaRng>>,
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<Error = anyhow::Error> {
&self.homeserver_connection
}

View File

@@ -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
"""

View File

@@ -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<Scalars['String']['input']>;
/** 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 */

View File

@@ -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",