Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/api/apierrors/errorcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ const (
ErrorCodeReauthenticationNeeded ErrorCode = "reauthentication_needed"
ErrorCodeSamePassword ErrorCode = "same_password"
ErrorCodeReauthenticationNotValid ErrorCode = "reauthentication_not_valid"
ErrorCodeCurrentPasswordMismatch ErrorCode = "current_password_invalid"
ErrorCodeCurrentPasswordRequired ErrorCode = "current_password_required"
ErrorCodeOTPExpired ErrorCode = "otp_expired"
ErrorCodeOTPDisabled ErrorCode = "otp_disabled"
ErrorCodeIdentityNotFound ErrorCode = "identity_not_found"
Expand Down
26 changes: 26 additions & 0 deletions internal/api/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
type UserUpdateParams struct {
Email string `json:"email"`
Password *string `json:"password"`
CurrentPassword *string `json:"current_password,omitempty"`
Nonce string `json:"nonce"`
Data map[string]interface{} `json:"data"`
AppData map[string]interface{} `json:"app_metadata,omitempty"`
Expand Down Expand Up @@ -165,6 +166,31 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
isSamePassword := false

if user.HasPassword() {
// current password required when updating password
if config.Security.UpdatePasswordRequireCurrentPassword {
// user may be in a password reset flow, where they do not have a currentPassword
isRecoverySession := false
for _, claim := range session.AMRClaims {
// password recovery flows can be via otp or a magic link, check if the current session
// was created with one of those
if claim.GetAuthenticationMethod() == "otp" || claim.GetAuthenticationMethod() == "magiclink" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Severity: HIGH

The AMR check to exempt recovery sessions is overly broad. Since 'otp' and 'magiclink' are used for standard passwordless sign-ins, any user authenticated via these methods can bypass the 'require current password' policy. This allow an attacker with access to a passwordless session to change the account password without knowing the existing one.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: Change the AMR check to specifically look for the 'recovery' authentication method instead of the overly broad 'otp' or 'magiclink' checks. The 'recovery' authentication method (models.Recovery) is the specific marker for password recovery flows, while 'otp' and 'magiclink' are used for standard passwordless sign-ins. This prevents users with regular OTP or MagicLink sessions from bypassing the current password requirement.

⚠️ Experimental Feature: This code suggestion is automatically generated. Please review carefully.

Suggested change
if claim.GetAuthenticationMethod() == "otp" || claim.GetAuthenticationMethod() == "magiclink" {
if claim.GetAuthenticationMethod() == "recovery" {

isRecoverySession = true
break
}
}
if !isRecoverySession {
if params.CurrentPassword == nil || *params.CurrentPassword == "" {
return apierrors.NewBadRequestError(apierrors.ErrorCodeCurrentPasswordRequired, "Current password required when setting new password.")
}
isCurrentPasswordCorrect, _, err := user.Authenticate(ctx, db, *params.CurrentPassword, config.Security.DBEncryption.DecryptionKeys, false, "")
if err != nil {
return err
}
if !isCurrentPasswordCorrect {
return apierrors.NewBadRequestError(apierrors.ErrorCodeCurrentPasswordMismatch, "Current password required when setting new password.")
}
}
}
auth, _, err := user.Authenticate(ctx, db, password, config.Security.DBEncryption.DecryptionKeys, false, "")
if err != nil {
return err
Expand Down
132 changes: 130 additions & 2 deletions internal/api/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,10 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
var cases = []struct {
desc string
newPassword string
currentPassword string
nonce string
requireReauthentication bool
requireCurrentPassword bool
sessionId *uuid.UUID
expected expected
}{
Expand Down Expand Up @@ -334,13 +336,48 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
sessionId: r.SessionId,
expected: expected{code: http.StatusOK, isAuthenticated: true},
},
{
desc: "Current password checked when require current password set",
newPassword: "updateToNewpassword123",
currentPassword: "newpassword123", // match to the test case above
nonce: "",
requireReauthentication: false,
requireCurrentPassword: true,
sessionId: r.SessionId,
expected: expected{code: http.StatusOK, isAuthenticated: true},
},
{
desc: "Fails if current password incorrect when require current password set",
newPassword: "newpassword123",
currentPassword: "randompassword",
nonce: "",
requireReauthentication: false,
requireCurrentPassword: true,
sessionId: r.SessionId,
expected: expected{code: http.StatusBadRequest, isAuthenticated: false},
},
{
desc: "Fails if current password not set when required",
newPassword: "newpassword123",
nonce: "",
requireReauthentication: false,
requireCurrentPassword: true,
sessionId: r.SessionId,
expected: expected{code: http.StatusBadRequest, isAuthenticated: false},
},
}

for _, c := range cases {
ts.Run(c.desc, func() {
ts.Config.Security.UpdatePasswordRequireReauthentication = c.requireReauthentication
ts.Config.Security.UpdatePasswordRequireCurrentPassword = c.requireCurrentPassword

userUpdateBody := map[string]string{"password": c.newPassword, "nonce": c.nonce}
if c.requireCurrentPassword {
userUpdateBody["current_password"] = c.currentPassword
}
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"password": c.newPassword, "nonce": c.nonce}))
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(userUpdateBody))

req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
req.Header.Set("Content-Type", "application/json")
Expand All @@ -365,7 +402,96 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
}
}

func (ts *UserTestSuite) TestUserUpdatePasswordViaRecovery() {
ts.Config.Security.UpdatePasswordRequireCurrentPassword = true
ts.Config.SMTP.MaxFrequency = 60
u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)
u.RecoverySentAt = &time.Time{}
require.NoError(ts.T(), ts.API.db.Update(u))

type expected struct {
code int
isAuthenticated bool
}

var cases = []struct {
desc string
newPassword string
currentPassword string
recoveryType models.AuthenticationMethod
expected expected
}{
{
desc: "Current password not required in OTP recovery flow",
newPassword: "newpassword123",
recoveryType: models.OTP,
expected: expected{code: http.StatusOK, isAuthenticated: true},
},
{
desc: "Current password not required in magiclink recovery flow",
newPassword: "newpassword456",
recoveryType: models.MagicLink,
expected: expected{code: http.StatusOK, isAuthenticated: true},
},
{
desc: "Current password required for any other claim",
newPassword: "newpassword456",
recoveryType: models.EmailChange,
expected: expected{code: http.StatusBadRequest, isAuthenticated: true},
},
}

for _, c := range cases {
ts.Run(c.desc, func() {
require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID))

// Create a session
session, err := models.NewSession(u.ID, nil)
require.NoError(ts.T(), err)
require.NoError(ts.T(), ts.API.db.Create(session))

// Add AMR claim to session to simulate recovery flow
require.NoError(ts.T(), models.AddClaimToSession(ts.API.db, session.ID, c.recoveryType))

// Reload session with AMR claims
session, err = models.FindSessionByID(ts.API.db, session.ID, true)
require.NoError(ts.T(), err)
require.NotEmpty(ts.T(), session.AMRClaims, "Session should have AMR claims")

// Generate access token with the recovery authentication method
req := httptest.NewRequest(http.MethodPut, "http://localhost/user", nil)
token, _, err := ts.API.generateAccessToken(req, ts.API.db, u, &session.ID, c.recoveryType)
require.NoError(ts.T(), err)

// Update password without current password
userUpdateBody := map[string]string{"password": c.newPassword}
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(userUpdateBody))

req = httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))

// Setup response recorder
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
require.Equal(ts.T(), c.expected.code, w.Code)

// Verify password was updated
u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)

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)
require.NoError(ts.T(), err)

require.Equal(ts.T(), c.expected.isAuthenticated, isAuthenticated)
})
}
}

func (ts *UserTestSuite) TestUserUpdatePasswordNoReauthenticationRequired() {
ts.Config.Security.UpdatePasswordRequireCurrentPassword = false
u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)

Expand Down Expand Up @@ -429,7 +555,7 @@ func (ts *UserTestSuite) TestUserUpdatePasswordNoReauthenticationRequired() {

func (ts *UserTestSuite) TestUserUpdatePasswordReauthentication() {
ts.Config.Security.UpdatePasswordRequireReauthentication = true

ts.Config.Security.UpdatePasswordRequireCurrentPassword = false
u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)

Expand Down Expand Up @@ -487,6 +613,7 @@ func (ts *UserTestSuite) TestUserUpdatePasswordReauthentication() {

func (ts *UserTestSuite) TestUserUpdatePasswordLogoutOtherSessions() {
ts.Config.Security.UpdatePasswordRequireReauthentication = false
ts.Config.Security.UpdatePasswordRequireCurrentPassword = false
u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)

Expand Down Expand Up @@ -586,6 +713,7 @@ func (ts *UserTestSuite) TestUserUpdatePasswordSendsNotificationEmail() {
for _, c := range cases {
ts.Run(c.desc, func() {
ts.Config.Security.UpdatePasswordRequireReauthentication = false
ts.Config.Security.UpdatePasswordRequireCurrentPassword = false
ts.Config.Mailer.Autoconfirm = false
ts.Config.Mailer.Notifications.PasswordChangedEnabled = c.notificationEnabled

Expand Down
1 change: 1 addition & 0 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,7 @@ type SecurityConfiguration struct {
RefreshTokenReuseInterval int `json:"refresh_token_reuse_interval" split_words:"true"`
RefreshTokenAllowReuse bool `json:"refresh_token_allow_reuse" split_words:"true"`
UpdatePasswordRequireReauthentication bool `json:"update_password_require_reauthentication" split_words:"true"`
UpdatePasswordRequireCurrentPassword bool `json:"update_password_require_current_password" split_words:"true"`
ManualLinkingEnabled bool `json:"manual_linking_enabled" split_words:"true" default:"false"`
SbForwardedForEnabled bool `json:"sb_forwarded_for_enabled" split_words:"true" default:"false"`

Expand Down