@@ -297,8 +297,10 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
297297 var cases = []struct {
298298 desc string
299299 newPassword string
300+ currentPassword string
300301 nonce string
301302 requireReauthentication bool
303+ requireCurrentPassword bool
302304 sessionId * uuid.UUID
303305 expected expected
304306 }{
@@ -334,13 +336,48 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
334336 sessionId : r .SessionId ,
335337 expected : expected {code : http .StatusOK , isAuthenticated : true },
336338 },
339+ {
340+ desc : "Current password checked when require current password set" ,
341+ newPassword : "updateToNewpassword123" ,
342+ currentPassword : "newpassword123" , // match to the test case above
343+ nonce : "" ,
344+ requireReauthentication : false ,
345+ requireCurrentPassword : true ,
346+ sessionId : r .SessionId ,
347+ expected : expected {code : http .StatusOK , isAuthenticated : true },
348+ },
349+ {
350+ desc : "Fails if current password incorrect when require current password set" ,
351+ newPassword : "newpassword123" ,
352+ currentPassword : "randompassword" ,
353+ nonce : "" ,
354+ requireReauthentication : false ,
355+ requireCurrentPassword : true ,
356+ sessionId : r .SessionId ,
357+ expected : expected {code : http .StatusBadRequest , isAuthenticated : false },
358+ },
359+ {
360+ desc : "Fails if current password not set when required" ,
361+ newPassword : "newpassword123" ,
362+ nonce : "" ,
363+ requireReauthentication : false ,
364+ requireCurrentPassword : true ,
365+ sessionId : r .SessionId ,
366+ expected : expected {code : http .StatusBadRequest , isAuthenticated : false },
367+ },
337368 }
338369
339370 for _ , c := range cases {
340371 ts .Run (c .desc , func () {
341372 ts .Config .Security .UpdatePasswordRequireReauthentication = c .requireReauthentication
373+ ts .Config .Security .UpdatePasswordRequireCurrentPassword = c .requireCurrentPassword
374+
375+ userUpdateBody := map [string ]string {"password" : c .newPassword , "nonce" : c .nonce }
376+ if c .requireCurrentPassword {
377+ userUpdateBody ["current_password" ] = c .currentPassword
378+ }
342379 var buffer bytes.Buffer
343- require .NoError (ts .T (), json .NewEncoder (& buffer ).Encode (map [ string ] string { "password" : c . newPassword , "nonce" : c . nonce } ))
380+ require .NoError (ts .T (), json .NewEncoder (& buffer ).Encode (userUpdateBody ))
344381
345382 req := httptest .NewRequest (http .MethodPut , "http://localhost/user" , & buffer )
346383 req .Header .Set ("Content-Type" , "application/json" )
@@ -365,7 +402,96 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
365402 }
366403}
367404
405+ func (ts * UserTestSuite ) TestUserUpdatePasswordViaRecovery () {
406+ ts .Config .Security .UpdatePasswordRequireCurrentPassword = true
407+ ts .Config .SMTP .MaxFrequency = 60
408+ u , err := models .FindUserByEmailAndAudience (ts .API .db , "test@example.com" , ts .Config .JWT .Aud )
409+ require .NoError (ts .T (), err )
410+ u .RecoverySentAt = & time.Time {}
411+ require .NoError (ts .T (), ts .API .db .Update (u ))
412+
413+ type expected struct {
414+ code int
415+ isAuthenticated bool
416+ }
417+
418+ var cases = []struct {
419+ desc string
420+ newPassword string
421+ currentPassword string
422+ recoveryType models.AuthenticationMethod
423+ expected expected
424+ }{
425+ {
426+ desc : "Current password not required in OTP recovery flow" ,
427+ newPassword : "newpassword123" ,
428+ recoveryType : models .OTP ,
429+ expected : expected {code : http .StatusOK , isAuthenticated : true },
430+ },
431+ {
432+ desc : "Current password not required in magiclink recovery flow" ,
433+ newPassword : "newpassword456" ,
434+ recoveryType : models .MagicLink ,
435+ expected : expected {code : http .StatusOK , isAuthenticated : true },
436+ },
437+ {
438+ desc : "Current password required for any other claim" ,
439+ newPassword : "newpassword456" ,
440+ recoveryType : models .EmailChange ,
441+ expected : expected {code : http .StatusBadRequest , isAuthenticated : true },
442+ },
443+ }
444+
445+ for _ , c := range cases {
446+ ts .Run (c .desc , func () {
447+ require .NoError (ts .T (), models .ClearAllOneTimeTokensForUser (ts .API .db , u .ID ))
448+
449+ // Create a session
450+ session , err := models .NewSession (u .ID , nil )
451+ require .NoError (ts .T (), err )
452+ require .NoError (ts .T (), ts .API .db .Create (session ))
453+
454+ // Add AMR claim to session to simulate recovery flow
455+ require .NoError (ts .T (), models .AddClaimToSession (ts .API .db , session .ID , c .recoveryType ))
456+
457+ // Reload session with AMR claims
458+ session , err = models .FindSessionByID (ts .API .db , session .ID , true )
459+ require .NoError (ts .T (), err )
460+ require .NotEmpty (ts .T (), session .AMRClaims , "Session should have AMR claims" )
461+
462+ // Generate access token with the recovery authentication method
463+ req := httptest .NewRequest (http .MethodPut , "http://localhost/user" , nil )
464+ token , _ , err := ts .API .generateAccessToken (req , ts .API .db , u , & session .ID , c .recoveryType )
465+ require .NoError (ts .T (), err )
466+
467+ // Update password without current password
468+ userUpdateBody := map [string ]string {"password" : c .newPassword }
469+ var buffer bytes.Buffer
470+ require .NoError (ts .T (), json .NewEncoder (& buffer ).Encode (userUpdateBody ))
471+
472+ req = httptest .NewRequest (http .MethodPut , "http://localhost/user" , & buffer )
473+ req .Header .Set ("Content-Type" , "application/json" )
474+ req .Header .Set ("Authorization" , fmt .Sprintf ("Bearer %s" , token ))
475+
476+ // Setup response recorder
477+ w := httptest .NewRecorder ()
478+ ts .API .handler .ServeHTTP (w , req )
479+ require .Equal (ts .T (), c .expected .code , w .Code )
480+
481+ // Verify password was updated
482+ u , err = models .FindUserByEmailAndAudience (ts .API .db , "test@example.com" , ts .Config .JWT .Aud )
483+ require .NoError (ts .T (), err )
484+
485+ isAuthenticated , _ , err := u .Authenticate (context .Background (), ts .API .db , c .newPassword , ts .API .config .Security .DBEncryption .DecryptionKeys , ts .API .config .Security .DBEncryption .Encrypt , ts .API .config .Security .DBEncryption .EncryptionKeyID )
486+ require .NoError (ts .T (), err )
487+
488+ require .Equal (ts .T (), c .expected .isAuthenticated , isAuthenticated )
489+ })
490+ }
491+ }
492+
368493func (ts * UserTestSuite ) TestUserUpdatePasswordNoReauthenticationRequired () {
494+ ts .Config .Security .UpdatePasswordRequireCurrentPassword = false
369495 u , err := models .FindUserByEmailAndAudience (ts .API .db , "test@example.com" , ts .Config .JWT .Aud )
370496 require .NoError (ts .T (), err )
371497
@@ -429,7 +555,7 @@ func (ts *UserTestSuite) TestUserUpdatePasswordNoReauthenticationRequired() {
429555
430556func (ts * UserTestSuite ) TestUserUpdatePasswordReauthentication () {
431557 ts .Config .Security .UpdatePasswordRequireReauthentication = true
432-
558+ ts . Config . Security . UpdatePasswordRequireCurrentPassword = false
433559 u , err := models .FindUserByEmailAndAudience (ts .API .db , "test@example.com" , ts .Config .JWT .Aud )
434560 require .NoError (ts .T (), err )
435561
@@ -487,6 +613,7 @@ func (ts *UserTestSuite) TestUserUpdatePasswordReauthentication() {
487613
488614func (ts * UserTestSuite ) TestUserUpdatePasswordLogoutOtherSessions () {
489615 ts .Config .Security .UpdatePasswordRequireReauthentication = false
616+ ts .Config .Security .UpdatePasswordRequireCurrentPassword = false
490617 u , err := models .FindUserByEmailAndAudience (ts .API .db , "test@example.com" , ts .Config .JWT .Aud )
491618 require .NoError (ts .T (), err )
492619
@@ -586,6 +713,7 @@ func (ts *UserTestSuite) TestUserUpdatePasswordSendsNotificationEmail() {
586713 for _ , c := range cases {
587714 ts .Run (c .desc , func () {
588715 ts .Config .Security .UpdatePasswordRequireReauthentication = false
716+ ts .Config .Security .UpdatePasswordRequireCurrentPassword = false
589717 ts .Config .Mailer .Autoconfirm = false
590718 ts .Config .Mailer .Notifications .PasswordChangedEnabled = c .notificationEnabled
591719
0 commit comments