66
77use anyhow:: Context as _;
88use async_graphql:: { Context , Description , Enum , ID , InputObject , Object } ;
9+ use mas_data_model:: SiteConfig ;
910use mas_i18n:: DataLocale ;
1011use 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 ) ]
2279pub struct UserEmailMutations {
2380 _private : ( ) ,
@@ -120,6 +177,10 @@ impl AddEmailPayload {
120177struct 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 ) ]
137201enum 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 ( )
0 commit comments