Skip to content

Commit dc9c91b

Browse files
committed
Add current password validation to self-service credential updates
Signed-off-by: janithjay <janithjayashan018@gmail.com>
1 parent 9255138 commit dc9c91b

13 files changed

Lines changed: 585 additions & 11 deletions

File tree

‎api/user.yaml‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1347,8 +1347,9 @@ paths:
13471347
content:
13481348
application/json:
13491349
schema:
1350-
$ref: '#/components/schemas/UpdateSelfUserRequest'
1350+
$ref: '#/components/schemas/UpdateSelfCredentialsRequest'
13511351
example:
1352+
currentPassword: "0ldP@ssword!"
13521353
attributes:
13531354
password: "n3wP@ssword!"
13541355
responses:
@@ -1371,6 +1372,16 @@ paths:
13711372
description:
13721373
key: "error.userservice.missing_credentials_description"
13731374
defaultValue: "At least one credential field must be provided"
1375+
unsupported-credential-type:
1376+
summary: A credential type other than password was supplied
1377+
value:
1378+
code: "USR-1024"
1379+
message:
1380+
key: "error.userservice.invalid_credential"
1381+
defaultValue: "Invalid request format"
1382+
description:
1383+
key: "error.userservice.invalid_credential_description"
1384+
defaultValue: "Invalid credential fields in request"
13741385
"401":
13751386
description: Unauthorized - missing or invalid authentication token
13761387
content:
@@ -1385,6 +1396,20 @@ paths:
13851396
description:
13861397
key: "error.unauthorized_description"
13871398
defaultValue: "Authentication is required to access this resource"
1399+
"403":
1400+
description: Forbidden - the supplied current password is incorrect
1401+
content:
1402+
application/json:
1403+
schema:
1404+
$ref: '#/components/schemas/Error'
1405+
example:
1406+
code: "USR-1029"
1407+
message:
1408+
key: "error.userservice.invalid_current_password"
1409+
defaultValue: "Invalid current password"
1410+
description:
1411+
key: "error.userservice.invalid_current_password_description"
1412+
defaultValue: "The provided current password is incorrect"
13881413
"404":
13891414
description: Authenticated user not found
13901415
content:
@@ -2191,6 +2216,27 @@ components:
21912216
description: "User attributes"
21922217
additionalProperties: true
21932218

2219+
UpdateSelfCredentialsRequest:
2220+
type: object
2221+
required: [attributes]
2222+
properties:
2223+
currentPassword:
2224+
type: string
2225+
format: password
2226+
description: "The user's existing password, verified before the write. Required once the account has a password. Omit it for a first-time set, on an account with no password yet."
2227+
example: "0ldP@ssword!"
2228+
attributes:
2229+
type: object
2230+
description: "Credential attributes to write. Only `password` is accepted here; other types are rejected with 400 USR-1024."
2231+
required: [password]
2232+
properties:
2233+
password:
2234+
type: string
2235+
format: password
2236+
description: "The new password to set."
2237+
example: "n3wP@ssword!"
2238+
additionalProperties: false
2239+
21942240
UpdateUserCredentialsRequest:
21952241
type: object
21962242
required: [credentials]

‎backend/internal/system/i18n/core/defaults.go‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎backend/internal/user/UserServiceInterface_mock_test.go‎

Lines changed: 71 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎backend/internal/user/constants.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ const (
1414
CredentialTypePasskey CredentialType = "passkey"
1515
)
1616

17+
// CredentialTypePassword is the schema-defined password credential. It is not system-managed, but
18+
// it is named here because it acts as the account-level proof of ownership when a user changes
19+
// their own credentials.
20+
const CredentialTypePassword CredentialType = "password"
21+
1722
// systemManagedCredentialTypes defines credential types that are managed by the system,
1823
// not through user types. These may support multiple values per user.
1924
var systemManagedCredentialTypes = []CredentialType{

‎backend/internal/user/error_constants.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,20 @@ var (
141141
DefaultValue: "At least one identifying attribute must be provided",
142142
},
143143
}
144+
// ErrorInvalidCurrentPassword is returned when the authenticated user's supplied current
145+
// password does not match the stored credential.
146+
ErrorInvalidCurrentPassword = tidcommon.ServiceError{
147+
Type: tidcommon.ClientErrorType,
148+
Code: "USR-1029",
149+
Error: tidcommon.I18nMessage{
150+
Key: "error.userservice.invalid_current_password",
151+
DefaultValue: "Invalid current password",
152+
},
153+
ErrorDescription: tidcommon.I18nMessage{
154+
Key: "error.userservice.invalid_current_password_description",
155+
DefaultValue: "The provided current password is incorrect",
156+
},
157+
}
144158
// ErrorMissingCredentials is the error returned when credentials are missing.
145159
ErrorMissingCredentials = tidcommon.ServiceError{
146160
Type: tidcommon.ClientErrorType,

‎backend/internal/user/handler.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ func (uh *userHandler) HandleSelfUserCredentialUpdateRequest(w http.ResponseWrit
459459
return
460460
}
461461

462-
updateRequest, err := sysutils.DecodeJSONBody[UpdateSelfUserRequest](r)
462+
updateRequest, err := sysutils.DecodeJSONBody[UpdateSelfCredentialsRequest](r)
463463
if err != nil {
464464
var valErr *sysutils.ValidationError
465465
if errors.As(err, &valErr) {
@@ -479,7 +479,8 @@ func (uh *userHandler) HandleSelfUserCredentialUpdateRequest(w http.ResponseWrit
479479
return
480480
}
481481

482-
if svcErr := uh.userService.UpdateUserCredentials(ctx, userID, updateRequest.Attributes); svcErr != nil {
482+
if svcErr := uh.userService.UpdateSelfUserCredentials(
483+
ctx, userID, updateRequest.CurrentPassword, updateRequest.Attributes); svcErr != nil {
483484
handleError(ctx, w, svcErr)
484485
return
485486
}
@@ -578,7 +579,8 @@ func handleError(ctx context.Context, w http.ResponseWriter, svcErr *tidcommon.S
578579
statusCode = http.StatusBadRequest
579580
case ErrorAuthenticationFailed.Code:
580581
statusCode = http.StatusUnauthorized
581-
case tidcommon.ErrorUnauthorized.Code:
582+
case tidcommon.ErrorUnauthorized.Code,
583+
ErrorInvalidCurrentPassword.Code:
582584
statusCode = http.StatusForbidden
583585
default:
584586
statusCode = http.StatusBadRequest

‎backend/internal/user/handler_test.go‎

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ func TestHandleSelfUserCredentialUpdateRequest_Success(t *testing.T) {
145145

146146
mockSvc := NewUserServiceInterfaceMock(t)
147147
credentialsJSON := json.RawMessage(`{"password":[{"value":"Secret123!"}]}`)
148-
mockSvc.On("UpdateUserCredentials", mock.Anything, userID, credentialsJSON).Return(nil)
148+
mockSvc.On("UpdateSelfUserCredentials", mock.Anything, userID, "", credentialsJSON).Return(nil)
149149

150150
handler := newUserHandler(mockSvc)
151151
req := httptest.NewRequest(http.MethodPost, "/users/me/update-credentials",
@@ -165,7 +165,7 @@ func TestHandleSelfUserCredentialUpdateRequest_StringValue(t *testing.T) {
165165

166166
mockSvc := NewUserServiceInterfaceMock(t)
167167
credentialsJSON := json.RawMessage(`{"password":"plaintext-password"}`)
168-
mockSvc.On("UpdateUserCredentials", mock.Anything, userID, credentialsJSON).Return(nil)
168+
mockSvc.On("UpdateSelfUserCredentials", mock.Anything, userID, "", credentialsJSON).Return(nil)
169169

170170
handler := newUserHandler(mockSvc)
171171
req := httptest.NewRequest(http.MethodPost, "/users/me/update-credentials",
@@ -241,7 +241,7 @@ func TestHandleSelfUserCredentialUpdateRequest_ErrorCases(t *testing.T) {
241241
for _, tc := range testCases {
242242
t.Run(tc.name, func(t *testing.T) {
243243
mockSvc := NewUserServiceInterfaceMock(t)
244-
mockSvc.On("UpdateUserCredentials", mock.Anything, userID, tc.mockJSON).Return(tc.mockError)
244+
mockSvc.On("UpdateSelfUserCredentials", mock.Anything, userID, "", tc.mockJSON).Return(tc.mockError)
245245

246246
handler := newUserHandler(mockSvc)
247247
req := httptest.NewRequest(http.MethodPost, "/users/me/update-credentials",
@@ -267,7 +267,7 @@ func TestHandleSelfUserCredentialUpdateRequest_MultipleCredentialTypes(t *testin
267267
mockSvc := NewUserServiceInterfaceMock(t)
268268
// Test that multiple credential types are updated in a single atomic call
269269
credentialsJSON := json.RawMessage(`{"password":"new-password","pin":"1234"}`)
270-
mockSvc.On("UpdateUserCredentials", mock.Anything, userID, credentialsJSON).Return(nil)
270+
mockSvc.On("UpdateSelfUserCredentials", mock.Anything, userID, "", credentialsJSON).Return(nil)
271271

272272
handler := newUserHandler(mockSvc)
273273
req := httptest.NewRequest(http.MethodPost, "/users/me/update-credentials",
@@ -279,8 +279,8 @@ func TestHandleSelfUserCredentialUpdateRequest_MultipleCredentialTypes(t *testin
279279

280280
require.Equal(t, http.StatusNoContent, rr.Code)
281281
require.Equal(t, 0, rr.Body.Len())
282-
// Verify that UpdateUserCredentials was called exactly once with all credentials
283-
mockSvc.AssertNumberOfCalls(t, "UpdateUserCredentials", 1)
282+
// Verify that UpdateSelfUserCredentials was called exactly once with all credentials
283+
mockSvc.AssertNumberOfCalls(t, "UpdateSelfUserCredentials", 1)
284284
}
285285

286286
func TestHandleUserCredentialUpdateRequest_Success(t *testing.T) {
@@ -1342,3 +1342,78 @@ func TestHandleSelfUserMetadataGetRequest_ServiceError(t *testing.T) {
13421342

13431343
require.Equal(t, http.StatusNotFound, rr.Code)
13441344
}
1345+
1346+
// ─────────────────────────────────────────────────────────────────────────────
1347+
// Self credential update – current-password plumbing
1348+
// ─────────────────────────────────────────────────────────────────────────────
1349+
1350+
func TestHandleSelfUserCredentialUpdateRequest_ForwardsCurrentPassword(t *testing.T) {
1351+
userID := testUserID789
1352+
authCtx := security.NewSecurityContextForTest(userID, "", "", nil, nil)
1353+
1354+
mockSvc := NewUserServiceInterfaceMock(t)
1355+
credentialsJSON := json.RawMessage(`{"password":"n3wP@ssword!"}`)
1356+
mockSvc.
1357+
On("UpdateSelfUserCredentials", mock.Anything, userID, "0ldP@ssword!", credentialsJSON).
1358+
Return(nil)
1359+
1360+
handler := newUserHandler(mockSvc)
1361+
req := httptest.NewRequest(http.MethodPost, "/users/me/update-credentials",
1362+
bytes.NewBufferString(`{"currentPassword":"0ldP@ssword!","attributes":{"password":"n3wP@ssword!"}}`))
1363+
req = req.WithContext(security.WithSecurityContextTest(req.Context(), authCtx))
1364+
rr := httptest.NewRecorder()
1365+
1366+
handler.HandleSelfUserCredentialUpdateRequest(rr, req)
1367+
1368+
require.Equal(t, http.StatusNoContent, rr.Code)
1369+
mockSvc.AssertNumberOfCalls(t, "UpdateSelfUserCredentials", 1)
1370+
}
1371+
1372+
func TestHandleSelfUserCredentialUpdateRequest_InvalidCurrentPasswordReturns403(t *testing.T) {
1373+
userID := testUserID789
1374+
authCtx := security.NewSecurityContextForTest(userID, "", "", nil, nil)
1375+
1376+
mockSvc := NewUserServiceInterfaceMock(t)
1377+
mockSvc.
1378+
On("UpdateSelfUserCredentials", mock.Anything, userID, "wrong", mock.Anything).
1379+
Return(&ErrorInvalidCurrentPassword)
1380+
1381+
handler := newUserHandler(mockSvc)
1382+
req := httptest.NewRequest(http.MethodPost, "/users/me/update-credentials",
1383+
bytes.NewBufferString(`{"currentPassword":"wrong","attributes":{"password":"n3wP@ssword!"}}`))
1384+
req = req.WithContext(security.WithSecurityContextTest(req.Context(), authCtx))
1385+
rr := httptest.NewRecorder()
1386+
1387+
handler.HandleSelfUserCredentialUpdateRequest(rr, req)
1388+
1389+
// 403 is what the SDK maps onto the current-password field, distinct from 401's
1390+
// "re-authenticate" meaning, so the status matters.
1391+
require.Equal(t, http.StatusForbidden, rr.Code)
1392+
1393+
var errResp apierror.ErrorResponse
1394+
require.NoError(t, json.NewDecoder(rr.Body).Decode(&errResp))
1395+
require.Equal(t, ErrorInvalidCurrentPassword.Code, errResp.Code)
1396+
}
1397+
1398+
// Guards the console's admin reset flow: it must keep calling UpdateUserCredentials, never the
1399+
// verifying self variant, since an admin can't know the target user's current password.
1400+
func TestHandleUserCredentialUpdateRequest_DoesNotRequireCurrentPassword(t *testing.T) {
1401+
userID := testUserID789
1402+
1403+
mockSvc := NewUserServiceInterfaceMock(t)
1404+
credentialsJSON := json.RawMessage(`{"password":"admin-reset-password"}`)
1405+
mockSvc.On("UpdateUserCredentials", mock.Anything, userID, credentialsJSON).Return(nil)
1406+
1407+
handler := newUserHandler(mockSvc)
1408+
req := httptest.NewRequest(http.MethodPost, "/users/"+userID+"/update-credentials",
1409+
bytes.NewBufferString(`{"credentials":{"password":"admin-reset-password"}}`))
1410+
req.SetPathValue("id", userID)
1411+
rr := httptest.NewRecorder()
1412+
1413+
handler.HandleUserCredentialUpdateRequest(rr, req)
1414+
1415+
require.Equal(t, http.StatusNoContent, rr.Code)
1416+
mockSvc.AssertNumberOfCalls(t, "UpdateUserCredentials", 1)
1417+
mockSvc.AssertNotCalled(t, "UpdateSelfUserCredentials",
1418+
mock.Anything, mock.Anything, mock.Anything, mock.Anything)
1419+
}

‎backend/internal/user/model.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ type UpdateSelfUserRequest struct {
7070
Attributes json.RawMessage `json:"attributes,omitempty"`
7171
}
7272

73+
// UpdateSelfCredentialsRequest represents the request body for the authenticated user changing
74+
// their own credentials. CurrentPassword is a sibling of Attributes, not a member of it, since the
75+
// entity layer only accepts schema-declared credential keys inside Attributes.
76+
type UpdateSelfCredentialsRequest struct {
77+
CurrentPassword string `json:"currentPassword,omitempty"`
78+
Attributes json.RawMessage `json:"attributes,omitempty"`
79+
}
80+
7381
// UpdateUserCredentialsRequest represents the request body for updating user credentials by an admin.
7482
type UpdateUserCredentialsRequest struct {
7583
Credentials json.RawMessage `json:"credentials,omitempty"`

0 commit comments

Comments
 (0)