Skip to content

Commit 09d185d

Browse files
committed
Require the user password to add or remove an email address
1 parent 7918a03 commit 09d185d

File tree

3 files changed

+149
-10
lines changed

3 files changed

+149
-10
lines changed

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

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,75 @@
66

77
use anyhow::Context as _;
88
use async_graphql::{Context, Description, Enum, ID, InputObject, Object};
9+
use mas_data_model::SiteConfig;
910
use mas_i18n::DataLocale;
1011
use mas_storage::{
11-
RepositoryAccess,
12+
BoxRepository, RepositoryAccess,
1213
queue::{ProvisionUserJob, QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob},
1314
user::{UserEmailFilter, UserEmailRepository, UserRepository},
1415
};
16+
use zeroize::Zeroizing;
1517

16-
use crate::graphql::{
17-
model::{NodeType, User, UserEmail, UserEmailAuthentication},
18-
state::ContextExt,
18+
use crate::{
19+
graphql::{
20+
Requester,
21+
model::{NodeType, User, UserEmail, UserEmailAuthentication},
22+
state::ContextExt,
23+
},
24+
passwords::PasswordManager,
1925
};
2026

27+
/// Check the password if neeed
28+
///
29+
/// Returns true if password verification is not needed, or if the password is
30+
/// correct. Returns false if the password is incorrect or missing.
31+
async fn verify_password_if_needed(
32+
requester: &Requester,
33+
config: &SiteConfig,
34+
password_manager: &PasswordManager,
35+
password: Option<String>,
36+
user: &mas_data_model::User,
37+
repo: &mut BoxRepository,
38+
) -> Result<bool, async_graphql::Error> {
39+
// If the requester is admin, they don't need to provide a password
40+
if requester.is_admin() {
41+
return Ok(true);
42+
}
43+
44+
// If password login is disabled, assume we don't want the user to reauth
45+
if !config.password_login_enabled {
46+
return Ok(true);
47+
}
48+
49+
// Else we need to check if the user has a password
50+
let Some(user_password) = repo
51+
.user_password()
52+
.active(user)
53+
.await
54+
.context("Failed to load user password")?
55+
else {
56+
// User has no password, so we don't need to verify the password
57+
return Ok(true);
58+
};
59+
60+
let Some(password) = password else {
61+
// There is a password on the user, but not provided in the input
62+
return Ok(false);
63+
};
64+
65+
let password = Zeroizing::new(password.into_bytes());
66+
67+
let res = password_manager
68+
.verify(
69+
user_password.version,
70+
password,
71+
user_password.hashed_password,
72+
)
73+
.await;
74+
75+
Ok(res.is_ok())
76+
}
77+
2178
#[derive(Default)]
2279
pub struct UserEmailMutations {
2380
_private: (),
@@ -120,6 +177,10 @@ impl AddEmailPayload {
120177
struct RemoveEmailInput {
121178
/// The ID of the email address to remove
122179
user_email_id: ID,
180+
181+
/// The user's current password. This is required if the user is not an
182+
/// admin and it has a password on its account.
183+
password: Option<String>,
123184
}
124185

125186
/// The status of the `removeEmail` mutation
@@ -130,13 +191,17 @@ enum RemoveEmailStatus {
130191

131192
/// The email address was not found
132193
NotFound,
194+
195+
/// The password provided is incorrect
196+
IncorrectPassword,
133197
}
134198

135199
/// The payload of the `removeEmail` mutation
136200
#[derive(Description)]
137201
enum RemoveEmailPayload {
138202
Removed(mas_data_model::UserEmail),
139203
NotFound,
204+
IncorrectPassword,
140205
}
141206

142207
#[Object(use_type_description)]
@@ -146,27 +211,31 @@ impl RemoveEmailPayload {
146211
match self {
147212
RemoveEmailPayload::Removed(_) => RemoveEmailStatus::Removed,
148213
RemoveEmailPayload::NotFound => RemoveEmailStatus::NotFound,
214+
RemoveEmailPayload::IncorrectPassword => RemoveEmailStatus::IncorrectPassword,
149215
}
150216
}
151217

152218
/// The email address that was removed
153219
async fn email(&self) -> Option<UserEmail> {
154220
match self {
155221
RemoveEmailPayload::Removed(email) => Some(UserEmail(email.clone())),
156-
RemoveEmailPayload::NotFound => None,
222+
RemoveEmailPayload::NotFound | RemoveEmailPayload::IncorrectPassword => None,
157223
}
158224
}
159225

160226
/// The user to whom the email address belonged
161227
async fn user(&self, ctx: &Context<'_>) -> Result<Option<User>, async_graphql::Error> {
162228
let state = ctx.state();
163-
let mut repo = state.repository().await?;
164229

165230
let user_id = match self {
166231
RemoveEmailPayload::Removed(email) => email.user_id,
167-
RemoveEmailPayload::NotFound => return Ok(None),
232+
RemoveEmailPayload::NotFound | RemoveEmailPayload::IncorrectPassword => {
233+
return Ok(None);
234+
}
168235
};
169236

237+
let mut repo = state.repository().await?;
238+
170239
let user = repo
171240
.user()
172241
.lookup(user_id)
@@ -226,6 +295,10 @@ struct StartEmailAuthenticationInput {
226295
/// The email address to add to the account
227296
email: String,
228297

298+
/// The user's current password. This is required if the user has a password
299+
/// on its account.
300+
password: Option<String>,
301+
229302
/// The language to use for the email
230303
#[graphql(default = "en")]
231304
language: String,
@@ -244,6 +317,8 @@ enum StartEmailAuthenticationStatus {
244317
Denied,
245318
/// The email address is already in use on this account
246319
InUse,
320+
/// The password provided is incorrect
321+
IncorrectPassword,
247322
}
248323

249324
/// The payload of the `startEmailAuthentication` mutation
@@ -256,6 +331,7 @@ enum StartEmailAuthenticationPayload {
256331
violations: Vec<mas_policy::Violation>,
257332
},
258333
InUse,
334+
IncorrectPassword,
259335
}
260336

261337
#[Object(use_type_description)]
@@ -268,16 +344,19 @@ impl StartEmailAuthenticationPayload {
268344
Self::RateLimited => StartEmailAuthenticationStatus::RateLimited,
269345
Self::Denied { .. } => StartEmailAuthenticationStatus::Denied,
270346
Self::InUse => StartEmailAuthenticationStatus::InUse,
347+
Self::IncorrectPassword => StartEmailAuthenticationStatus::IncorrectPassword,
271348
}
272349
}
273350

274351
/// The email authentication session that was started
275352
async fn authentication(&self) -> Option<&UserEmailAuthentication> {
276353
match self {
277354
Self::Started(authentication) => Some(authentication),
278-
Self::InvalidEmailAddress | Self::RateLimited | Self::Denied { .. } | Self::InUse => {
279-
None
280-
}
355+
Self::InvalidEmailAddress
356+
| Self::RateLimited
357+
| Self::Denied { .. }
358+
| Self::InUse
359+
| Self::IncorrectPassword => None,
281360
}
282361
}
283362

@@ -494,6 +573,20 @@ impl UserEmailMutations {
494573
.await?
495574
.context("Failed to load user")?;
496575

576+
// Validate the password input if needed
577+
if !verify_password_if_needed(
578+
requester,
579+
state.site_config(),
580+
&state.password_manager(),
581+
input.password,
582+
&user,
583+
&mut repo,
584+
)
585+
.await?
586+
{
587+
return Ok(RemoveEmailPayload::IncorrectPassword);
588+
}
589+
497590
// TODO: don't allow removing the last email address
498591

499592
repo.user_email().remove(user_email.clone()).await?;
@@ -627,6 +720,20 @@ impl UserEmailMutations {
627720
});
628721
}
629722

723+
// Validate the password input if needed
724+
if !verify_password_if_needed(
725+
requester,
726+
state.site_config(),
727+
&state.password_manager(),
728+
input.password,
729+
&browser_session.user,
730+
&mut repo,
731+
)
732+
.await?
733+
{
734+
return Ok(StartEmailAuthenticationPayload::IncorrectPassword);
735+
}
736+
630737
// Create a new authentication session
631738
let authentication = repo
632739
.user_email()

frontend/schema.graphql

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,6 +1203,11 @@ input RemoveEmailInput {
12031203
The ID of the email address to remove
12041204
"""
12051205
userEmailId: ID!
1206+
"""
1207+
The user's current password. This is required if the user is not an
1208+
admin and it has a password on its account.
1209+
"""
1210+
password: String
12061211
}
12071212

12081213
"""
@@ -1235,6 +1240,10 @@ enum RemoveEmailStatus {
12351240
The email address was not found
12361241
"""
12371242
NOT_FOUND
1243+
"""
1244+
The password provided is incorrect
1245+
"""
1246+
INCORRECT_PASSWORD
12381247
}
12391248

12401249
"""
@@ -1610,6 +1619,11 @@ input StartEmailAuthenticationInput {
16101619
"""
16111620
email: String!
16121621
"""
1622+
The user's current password. This is required if the user has a password
1623+
on its account.
1624+
"""
1625+
password: String
1626+
"""
16131627
The language to use for the email
16141628
"""
16151629
language: String! = "en"
@@ -1657,6 +1671,10 @@ enum StartEmailAuthenticationStatus {
16571671
The email address is already in use on this account
16581672
"""
16591673
IN_USE
1674+
"""
1675+
The password provided is incorrect
1676+
"""
1677+
INCORRECT_PASSWORD
16601678
}
16611679

16621680
"""

frontend/src/gql/graphql.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,11 @@ export type QueryUsersArgs = {
935935

936936
/** The input for the `removeEmail` mutation */
937937
export type RemoveEmailInput = {
938+
/**
939+
* The user's current password. This is required if the user is not an
940+
* admin and it has a password on its account.
941+
*/
942+
password?: InputMaybe<Scalars['String']['input']>;
938943
/** The ID of the email address to remove */
939944
userEmailId: Scalars['ID']['input'];
940945
};
@@ -952,6 +957,8 @@ export type RemoveEmailPayload = {
952957

953958
/** The status of the `removeEmail` mutation */
954959
export type RemoveEmailStatus =
960+
/** The password provided is incorrect */
961+
| 'INCORRECT_PASSWORD'
955962
/** The email address was not found */
956963
| 'NOT_FOUND'
957964
/** The email address was removed */
@@ -1190,6 +1197,11 @@ export type StartEmailAuthenticationInput = {
11901197
email: Scalars['String']['input'];
11911198
/** The language to use for the email */
11921199
language?: Scalars['String']['input'];
1200+
/**
1201+
* The user's current password. This is required if the user has a password
1202+
* on its account.
1203+
*/
1204+
password?: InputMaybe<Scalars['String']['input']>;
11931205
};
11941206

11951207
/** The payload of the `startEmailAuthentication` mutation */
@@ -1207,6 +1219,8 @@ export type StartEmailAuthenticationPayload = {
12071219
export type StartEmailAuthenticationStatus =
12081220
/** The email address isn't allowed by the policy */
12091221
| 'DENIED'
1222+
/** The password provided is incorrect */
1223+
| 'INCORRECT_PASSWORD'
12101224
/** The email address is invalid */
12111225
| 'INVALID_EMAIL_ADDRESS'
12121226
/** The email address is already in use on this account */

0 commit comments

Comments
 (0)