diff --git a/api/user.yaml b/api/user.yaml index 4fcf9422dd..54537bbc34 100644 --- a/api/user.yaml +++ b/api/user.yaml @@ -1347,8 +1347,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UpdateSelfUserRequest' + $ref: '#/components/schemas/UpdateSelfCredentialsRequest' example: + currentPassword: "0ldP@ssword!" attributes: password: "n3wP@ssword!" responses: @@ -1371,6 +1372,16 @@ paths: description: key: "error.userservice.missing_credentials_description" defaultValue: "At least one credential field must be provided" + unsupported-credential-type: + summary: A credential type other than password was supplied + value: + code: "USR-1024" + message: + key: "error.userservice.invalid_credential" + defaultValue: "Invalid request format" + description: + key: "error.userservice.invalid_credential_description" + defaultValue: "Invalid credential fields in request" "401": description: Unauthorized - missing or invalid authentication token content: @@ -1385,6 +1396,20 @@ paths: description: key: "error.unauthorized_description" defaultValue: "Authentication is required to access this resource" + "403": + description: Forbidden - the supplied current password is incorrect + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: "USR-1029" + message: + key: "error.userservice.invalid_current_password" + defaultValue: "Invalid current password" + description: + key: "error.userservice.invalid_current_password_description" + defaultValue: "The provided current password is incorrect" "404": description: Authenticated user not found content: @@ -2191,6 +2216,27 @@ components: description: "User attributes" additionalProperties: true + UpdateSelfCredentialsRequest: + type: object + required: [attributes] + properties: + currentPassword: + type: string + format: password + 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." + example: "0ldP@ssword!" + attributes: + type: object + description: "Credential attributes to write. Only `password` is accepted here; other types are rejected with 400 USR-1024." + required: [password] + properties: + password: + type: string + format: password + description: "The new password to set." + example: "n3wP@ssword!" + additionalProperties: false + UpdateUserCredentialsRequest: type: object required: [credentials] diff --git a/backend/internal/system/i18n/core/defaults.go b/backend/internal/system/i18n/core/defaults.go index f4ffe2e79f..d86746a6e1 100644 --- a/backend/internal/system/i18n/core/defaults.go +++ b/backend/internal/system/i18n/core/defaults.go @@ -1073,6 +1073,8 @@ var defaultMessages = map[string]string{ "error.userservice.handle_path_required_description": "Handle path is required for this operation", "error.userservice.invalid_credential": "Invalid request format", "error.userservice.invalid_credential_description": "Invalid credential fields in request", + "error.userservice.invalid_current_password": "Invalid current password", + "error.userservice.invalid_current_password_description": "The provided current password is incorrect", "error.userservice.invalid_filter_parameter": "Invalid filter parameter", "error.userservice.invalid_filter_parameter_description": "The filter format is invalid", "error.userservice.invalid_handle_path": "Invalid handle path", diff --git a/backend/internal/user/UserServiceInterface_mock_test.go b/backend/internal/user/UserServiceInterface_mock_test.go index 37c12d7a5a..61355ad78a 100644 --- a/backend/internal/user/UserServiceInterface_mock_test.go +++ b/backend/internal/user/UserServiceInterface_mock_test.go @@ -826,6 +826,77 @@ func (_c *UserServiceInterfaceMock_SetDependencyRegistry_Call) RunAndReturn(run return _c } +// UpdateSelfUserPassword provides a mock function for the type UserServiceInterfaceMock +func (_mock *UserServiceInterfaceMock) UpdateSelfUserPassword(ctx context.Context, userID string, currentPassword string, credentials json.RawMessage) *common.ServiceError { + ret := _mock.Called(ctx, userID, currentPassword, credentials) + + if len(ret) == 0 { + panic("no return value specified for UpdateSelfUserPassword") + } + + var r0 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, json.RawMessage) *common.ServiceError); ok { + r0 = returnFunc(ctx, userID, currentPassword, credentials) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*common.ServiceError) + } + } + return r0 +} + +// UserServiceInterfaceMock_UpdateSelfUserPassword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateSelfUserPassword' +type UserServiceInterfaceMock_UpdateSelfUserPassword_Call struct { + *mock.Call +} + +// UpdateSelfUserPassword is a helper method to define mock.On call +// - ctx context.Context +// - userID string +// - currentPassword string +// - credentials json.RawMessage +func (_e *UserServiceInterfaceMock_Expecter) UpdateSelfUserPassword(ctx interface{}, userID interface{}, currentPassword interface{}, credentials interface{}) *UserServiceInterfaceMock_UpdateSelfUserPassword_Call { + return &UserServiceInterfaceMock_UpdateSelfUserPassword_Call{Call: _e.mock.On("UpdateSelfUserPassword", ctx, userID, currentPassword, credentials)} +} + +func (_c *UserServiceInterfaceMock_UpdateSelfUserPassword_Call) Run(run func(ctx context.Context, userID string, currentPassword string, credentials json.RawMessage)) *UserServiceInterfaceMock_UpdateSelfUserPassword_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 json.RawMessage + if args[3] != nil { + arg3 = args[3].(json.RawMessage) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *UserServiceInterfaceMock_UpdateSelfUserPassword_Call) Return(serviceError *common.ServiceError) *UserServiceInterfaceMock_UpdateSelfUserPassword_Call { + _c.Call.Return(serviceError) + return _c +} + +func (_c *UserServiceInterfaceMock_UpdateSelfUserPassword_Call) RunAndReturn(run func(ctx context.Context, userID string, currentPassword string, credentials json.RawMessage) *common.ServiceError) *UserServiceInterfaceMock_UpdateSelfUserPassword_Call { + _c.Call.Return(run) + return _c +} + // UpdateUser provides a mock function for the type UserServiceInterfaceMock func (_mock *UserServiceInterfaceMock) UpdateUser(ctx context.Context, userID string, user *providers.User) (*providers.User, *common.ServiceError) { ret := _mock.Called(ctx, userID, user) diff --git a/backend/internal/user/constants.go b/backend/internal/user/constants.go index 53c7f0e2cf..297873f27a 100644 --- a/backend/internal/user/constants.go +++ b/backend/internal/user/constants.go @@ -14,6 +14,11 @@ const ( CredentialTypePasskey CredentialType = "passkey" ) +// CredentialTypePassword is the schema-defined password credential. It is not system-managed, but +// it is named here because it acts as the account-level proof of ownership when a user changes +// their own credentials. +const CredentialTypePassword CredentialType = "password" + // systemManagedCredentialTypes defines credential types that are managed by the system, // not through user types. These may support multiple values per user. var systemManagedCredentialTypes = []CredentialType{ diff --git a/backend/internal/user/error_constants.go b/backend/internal/user/error_constants.go index 0d112607af..171eb3e57e 100644 --- a/backend/internal/user/error_constants.go +++ b/backend/internal/user/error_constants.go @@ -141,6 +141,20 @@ var ( DefaultValue: "At least one identifying attribute must be provided", }, } + // ErrorInvalidCurrentPassword is returned when the authenticated user's supplied current + // password does not match the stored credential. + ErrorInvalidCurrentPassword = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "USR-1029", + Error: tidcommon.I18nMessage{ + Key: "error.userservice.invalid_current_password", + DefaultValue: "Invalid current password", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.userservice.invalid_current_password_description", + DefaultValue: "The provided current password is incorrect", + }, + } // ErrorMissingCredentials is the error returned when credentials are missing. ErrorMissingCredentials = tidcommon.ServiceError{ Type: tidcommon.ClientErrorType, diff --git a/backend/internal/user/handler.go b/backend/internal/user/handler.go index 4ee5f7a0aa..a0543c8ef3 100644 --- a/backend/internal/user/handler.go +++ b/backend/internal/user/handler.go @@ -459,7 +459,7 @@ func (uh *userHandler) HandleSelfUserCredentialUpdateRequest(w http.ResponseWrit return } - updateRequest, err := sysutils.DecodeJSONBody[UpdateSelfUserRequest](r) + updateRequest, err := sysutils.DecodeJSONBody[UpdateSelfCredentialsRequest](r) if err != nil { var valErr *sysutils.ValidationError if errors.As(err, &valErr) { @@ -479,7 +479,8 @@ func (uh *userHandler) HandleSelfUserCredentialUpdateRequest(w http.ResponseWrit return } - if svcErr := uh.userService.UpdateUserCredentials(ctx, userID, updateRequest.Attributes); svcErr != nil { + if svcErr := uh.userService.UpdateSelfUserPassword( + ctx, userID, updateRequest.CurrentPassword, updateRequest.Attributes); svcErr != nil { handleError(ctx, w, svcErr) return } @@ -578,7 +579,8 @@ func handleError(ctx context.Context, w http.ResponseWriter, svcErr *tidcommon.S statusCode = http.StatusBadRequest case ErrorAuthenticationFailed.Code: statusCode = http.StatusUnauthorized - case tidcommon.ErrorUnauthorized.Code: + case tidcommon.ErrorUnauthorized.Code, + ErrorInvalidCurrentPassword.Code: statusCode = http.StatusForbidden default: statusCode = http.StatusBadRequest diff --git a/backend/internal/user/handler_test.go b/backend/internal/user/handler_test.go index 1ae7a46e65..96be37731a 100644 --- a/backend/internal/user/handler_test.go +++ b/backend/internal/user/handler_test.go @@ -145,7 +145,7 @@ func TestHandleSelfUserCredentialUpdateRequest_Success(t *testing.T) { mockSvc := NewUserServiceInterfaceMock(t) credentialsJSON := json.RawMessage(`{"password":[{"value":"Secret123!"}]}`) - mockSvc.On("UpdateUserCredentials", mock.Anything, userID, credentialsJSON).Return(nil) + mockSvc.On("UpdateSelfUserPassword", mock.Anything, userID, "", credentialsJSON).Return(nil) handler := newUserHandler(mockSvc) req := httptest.NewRequest(http.MethodPost, "/users/me/update-credentials", @@ -165,7 +165,7 @@ func TestHandleSelfUserCredentialUpdateRequest_StringValue(t *testing.T) { mockSvc := NewUserServiceInterfaceMock(t) credentialsJSON := json.RawMessage(`{"password":"plaintext-password"}`) - mockSvc.On("UpdateUserCredentials", mock.Anything, userID, credentialsJSON).Return(nil) + mockSvc.On("UpdateSelfUserPassword", mock.Anything, userID, "", credentialsJSON).Return(nil) handler := newUserHandler(mockSvc) req := httptest.NewRequest(http.MethodPost, "/users/me/update-credentials", @@ -241,7 +241,7 @@ func TestHandleSelfUserCredentialUpdateRequest_ErrorCases(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { mockSvc := NewUserServiceInterfaceMock(t) - mockSvc.On("UpdateUserCredentials", mock.Anything, userID, tc.mockJSON).Return(tc.mockError) + mockSvc.On("UpdateSelfUserPassword", mock.Anything, userID, "", tc.mockJSON).Return(tc.mockError) handler := newUserHandler(mockSvc) req := httptest.NewRequest(http.MethodPost, "/users/me/update-credentials", @@ -267,7 +267,7 @@ func TestHandleSelfUserCredentialUpdateRequest_MultipleCredentialTypes(t *testin mockSvc := NewUserServiceInterfaceMock(t) // Test that multiple credential types are updated in a single atomic call credentialsJSON := json.RawMessage(`{"password":"new-password","pin":"1234"}`) - mockSvc.On("UpdateUserCredentials", mock.Anything, userID, credentialsJSON).Return(nil) + mockSvc.On("UpdateSelfUserPassword", mock.Anything, userID, "", credentialsJSON).Return(nil) handler := newUserHandler(mockSvc) req := httptest.NewRequest(http.MethodPost, "/users/me/update-credentials", @@ -279,8 +279,8 @@ func TestHandleSelfUserCredentialUpdateRequest_MultipleCredentialTypes(t *testin require.Equal(t, http.StatusNoContent, rr.Code) require.Equal(t, 0, rr.Body.Len()) - // Verify that UpdateUserCredentials was called exactly once with all credentials - mockSvc.AssertNumberOfCalls(t, "UpdateUserCredentials", 1) + // Verify that UpdateSelfUserPassword was called exactly once with all credentials + mockSvc.AssertNumberOfCalls(t, "UpdateSelfUserPassword", 1) } func TestHandleUserCredentialUpdateRequest_Success(t *testing.T) { @@ -1342,3 +1342,78 @@ func TestHandleSelfUserMetadataGetRequest_ServiceError(t *testing.T) { require.Equal(t, http.StatusNotFound, rr.Code) } + +// ───────────────────────────────────────────────────────────────────────────── +// Self credential update – current-password plumbing +// ───────────────────────────────────────────────────────────────────────────── + +func TestHandleSelfUserCredentialUpdateRequest_ForwardsCurrentPassword(t *testing.T) { + userID := testUserID789 + authCtx := security.NewSecurityContextForTest(userID, "", "", nil, nil) + + mockSvc := NewUserServiceInterfaceMock(t) + credentialsJSON := json.RawMessage(`{"password":"n3wP@ssword!"}`) + mockSvc. + On("UpdateSelfUserPassword", mock.Anything, userID, "0ldP@ssword!", credentialsJSON). + Return(nil) + + handler := newUserHandler(mockSvc) + req := httptest.NewRequest(http.MethodPost, "/users/me/update-credentials", + bytes.NewBufferString(`{"currentPassword":"0ldP@ssword!","attributes":{"password":"n3wP@ssword!"}}`)) + req = req.WithContext(security.WithSecurityContextTest(req.Context(), authCtx)) + rr := httptest.NewRecorder() + + handler.HandleSelfUserCredentialUpdateRequest(rr, req) + + require.Equal(t, http.StatusNoContent, rr.Code) + mockSvc.AssertNumberOfCalls(t, "UpdateSelfUserPassword", 1) +} + +func TestHandleSelfUserCredentialUpdateRequest_InvalidCurrentPasswordReturns403(t *testing.T) { + userID := testUserID789 + authCtx := security.NewSecurityContextForTest(userID, "", "", nil, nil) + + mockSvc := NewUserServiceInterfaceMock(t) + mockSvc. + On("UpdateSelfUserPassword", mock.Anything, userID, "wrong", mock.Anything). + Return(&ErrorInvalidCurrentPassword) + + handler := newUserHandler(mockSvc) + req := httptest.NewRequest(http.MethodPost, "/users/me/update-credentials", + bytes.NewBufferString(`{"currentPassword":"wrong","attributes":{"password":"n3wP@ssword!"}}`)) + req = req.WithContext(security.WithSecurityContextTest(req.Context(), authCtx)) + rr := httptest.NewRecorder() + + handler.HandleSelfUserCredentialUpdateRequest(rr, req) + + // 403 is what the SDK maps onto the current-password field, distinct from 401's + // "re-authenticate" meaning, so the status matters. + require.Equal(t, http.StatusForbidden, rr.Code) + + var errResp apierror.ErrorResponse + require.NoError(t, json.NewDecoder(rr.Body).Decode(&errResp)) + require.Equal(t, ErrorInvalidCurrentPassword.Code, errResp.Code) +} + +// Guards the console's admin reset flow: it must keep calling UpdateUserCredentials, never the +// verifying self variant, since an admin can't know the target user's current password. +func TestHandleUserCredentialUpdateRequest_DoesNotRequireCurrentPassword(t *testing.T) { + userID := testUserID789 + + mockSvc := NewUserServiceInterfaceMock(t) + credentialsJSON := json.RawMessage(`{"password":"admin-reset-password"}`) + mockSvc.On("UpdateUserCredentials", mock.Anything, userID, credentialsJSON).Return(nil) + + handler := newUserHandler(mockSvc) + req := httptest.NewRequest(http.MethodPost, "/users/"+userID+"/update-credentials", + bytes.NewBufferString(`{"credentials":{"password":"admin-reset-password"}}`)) + req.SetPathValue("id", userID) + rr := httptest.NewRecorder() + + handler.HandleUserCredentialUpdateRequest(rr, req) + + require.Equal(t, http.StatusNoContent, rr.Code) + mockSvc.AssertNumberOfCalls(t, "UpdateUserCredentials", 1) + mockSvc.AssertNotCalled(t, "UpdateSelfUserPassword", + mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} diff --git a/backend/internal/user/model.go b/backend/internal/user/model.go index a5c55f53ff..10bbff1903 100644 --- a/backend/internal/user/model.go +++ b/backend/internal/user/model.go @@ -70,6 +70,14 @@ type UpdateSelfUserRequest struct { Attributes json.RawMessage `json:"attributes,omitempty"` } +// UpdateSelfCredentialsRequest represents the request body for the authenticated user changing +// their own credentials. CurrentPassword is a sibling of Attributes, not a member of it, since the +// entity layer only accepts schema-declared credential keys inside Attributes. +type UpdateSelfCredentialsRequest struct { + CurrentPassword string `json:"currentPassword,omitempty"` + Attributes json.RawMessage `json:"attributes,omitempty"` +} + // UpdateUserCredentialsRequest represents the request body for updating user credentials by an admin. type UpdateUserCredentialsRequest struct { Credentials json.RawMessage `json:"credentials,omitempty"` diff --git a/backend/internal/user/service.go b/backend/internal/user/service.go index 056b4f1014..7942d5580e 100644 --- a/backend/internal/user/service.go +++ b/backend/internal/user/service.go @@ -47,6 +47,8 @@ type UserServiceInterface interface { GetUserMetadata(ctx context.Context, userID string) (*entitytype.EntityType, *tidcommon.ServiceError) UpdateUserCredentials(ctx context.Context, userID string, credentials json.RawMessage) *tidcommon.ServiceError + UpdateSelfUserPassword(ctx context.Context, userID string, currentPassword string, + credentials json.RawMessage) *tidcommon.ServiceError DeleteUser(ctx context.Context, userID string) *tidcommon.ServiceError ValidateDeleteUser(ctx context.Context, userID string) *tidcommon.ServiceError ResolveUserOUHandle(ctx context.Context, user *providers.User) *tidcommon.ServiceError @@ -687,6 +689,86 @@ func (us *userService) UpdateUserAttributes( return &existingUser, nil } +// UpdateSelfUserPassword updates the authenticated user's own password after verifying their +// current one. Kept separate from UpdateUserCredentials (the admin reset path), since an admin +// can't supply the target's current password. +// A user with no password stored yet is treated as a first-time set. +func (us *userService) UpdateSelfUserPassword( + ctx context.Context, + userID string, + currentPassword string, + credentials json.RawMessage, +) *tidcommon.ServiceError { + logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, loggerComponentName)) + + if strings.TrimSpace(userID) == "" { + return &ErrorAuthenticationFailed + } + + if svcErr := validateSelfCredentialPayload(credentials); svcErr != nil { + return svcErr + } + + stored, err := us.entityService.GetCredentialsByType(ctx, userID, string(CredentialTypePassword)) + if err != nil { + if errors.Is(err, entity.ErrEntityNotFound) { + return &ErrorUserNotFound + } + return logErrorAndReturnServerError(ctx, logger, "Failed to read stored credentials", err, + log.MaskedString(log.LoggerKeyUserID, userID)) + } + + if len(stored) > 0 { + if strings.TrimSpace(currentPassword) == "" { + return &ErrorInvalidCurrentPassword + } + + _, authErr := us.entityService.AuthenticateEntityByID( + ctx, userID, map[string]interface{}{string(CredentialTypePassword): currentPassword}) + if authErr != nil { + switch { + case errors.Is(authErr, entity.ErrAuthenticationFailed): + logger.Debug(ctx, "Current password verification failed", + log.MaskedString(log.LoggerKeyUserID, userID)) + return &ErrorInvalidCurrentPassword + case errors.Is(authErr, entity.ErrEntityNotFound): + return &ErrorUserNotFound + default: + return logErrorAndReturnServerError(ctx, logger, "Failed to verify current password", authErr, + log.MaskedString(log.LoggerKeyUserID, userID)) + } + } + } + + return us.UpdateUserCredentials(ctx, userID, credentials) +} + +// validateSelfCredentialPayload restricts a self-service credential write to the password. +// Rejecting anything else is checked before the stored password is read, so an invalid payload +// costs no lookup and reveals nothing about whether the account has a password set. +func validateSelfCredentialPayload(credentials json.RawMessage) *tidcommon.ServiceError { + if len(credentials) == 0 { + return &ErrorMissingCredentials + } + + var credentialsMap map[string]json.RawMessage + if err := json.Unmarshal(credentials, &credentialsMap); err != nil { + return &ErrorInvalidRequestFormat + } + + if len(credentialsMap) == 0 { + return &ErrorMissingCredentials + } + + for credType := range credentialsMap { + if CredentialType(credType) != CredentialTypePassword { + return &ErrorInvalidCredential + } + } + + return nil +} + // UpdateUserCredentials updates schema-defined credentials for a user. func (us *userService) UpdateUserCredentials( ctx context.Context, diff --git a/backend/internal/user/service_test.go b/backend/internal/user/service_test.go index 748542622d..8e060a37d2 100644 --- a/backend/internal/user/service_test.go +++ b/backend/internal/user/service_test.go @@ -3923,3 +3923,286 @@ func TestGetUserMetadata_GetEntityTypeSchemaError(t *testing.T) { require.NotNil(t, svcErr) require.Equal(t, entitytype.ErrorEntityTypeNotFound.Code, svcErr.Code) } + +// ───────────────────────────────────────────────────────────────────────────── +// UpdateSelfUserPassword – current-password verification +// ───────────────────────────────────────────────────────────────────────────── + +// storedPasswordCredential returns a stored credential value standing in for a hashed password. +// The hash is never inspected: AuthenticateEntityByID is mocked, so only its presence matters. +func storedPasswordCredential() []entitypkg.StoredCredential { + return []entitypkg.StoredCredential{{Value: "hashed-current-password"}} +} + +func TestUserService_UpdateSelfUserPassword_RejectsMissingUserID(t *testing.T) { + service := &userService{} + + err := service.UpdateSelfUserPassword( + context.Background(), "", "0ldP@ss", json.RawMessage(`{"password":"n3wP@ss"}`)) + + require.NotNil(t, err) + require.Equal(t, ErrorAuthenticationFailed, *err) +} + +func TestUserService_UpdateSelfUserPassword_RejectsWrongCurrentPassword(t *testing.T) { + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + entityMock. + On("GetCredentialsByType", mock.Anything, svcTestUserID1, "password"). + Return(storedPasswordCredential(), nil). + Once() + entityMock. + On("AuthenticateEntityByID", mock.Anything, svcTestUserID1, + map[string]interface{}{"password": "wrong-password"}). + Return(nil, entitypkg.ErrAuthenticationFailed). + Once() + + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, "wrong-password", json.RawMessage(`{"password":"n3wP@ss"}`)) + + require.NotNil(t, err) + require.Equal(t, ErrorInvalidCurrentPassword, *err) + // The write must not be attempted when verification fails. + entityMock.AssertNotCalled(t, "UpdateCredentials", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUserService_UpdateSelfUserPassword_RejectsEmptyCurrentPasswordWhenOneIsSet(t *testing.T) { + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + entityMock. + On("GetCredentialsByType", mock.Anything, svcTestUserID1, "password"). + Return(storedPasswordCredential(), nil). + Once() + + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, " ", json.RawMessage(`{"password":"n3wP@ss"}`)) + + require.NotNil(t, err) + require.Equal(t, ErrorInvalidCurrentPassword, *err) + // Verification is skipped entirely when nothing was supplied. + entityMock.AssertNotCalled(t, "AuthenticateEntityByID", + mock.Anything, mock.Anything, mock.Anything) + entityMock.AssertNotCalled(t, "UpdateCredentials", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUserService_UpdateSelfUserPassword_WritesWhenCurrentPasswordMatches(t *testing.T) { + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + entityMock.On("IsEntityDeclarative", mock.Anything, mock.Anything).Return(false, nil).Maybe() + entityMock. + On("GetCredentialsByType", mock.Anything, svcTestUserID1, "password"). + Return(storedPasswordCredential(), nil). + Once() + entityMock. + On("AuthenticateEntityByID", mock.Anything, svcTestUserID1, + map[string]interface{}{"password": "0ldP@ss"}). + Return(&entitypkg.AuthenticateResult{EntityID: svcTestUserID1}, nil). + Once() + entityMock. + On("GetEntity", mock.Anything, svcTestUserID1). + Return(&providers.Entity{ + Category: providers.EntityCategoryUser, ID: svcTestUserID1, Type: "Person", + }, nil). + Once() + entityMock. + On("UpdateCredentials", mock.Anything, svcTestUserID1, mock.Anything). + Return(nil). + Once() + + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, "0ldP@ss", json.RawMessage(`{"password":"n3wP@ss"}`)) + + require.Nil(t, err) + entityMock.AssertNumberOfCalls(t, "UpdateCredentials", 1) +} + +func TestUserService_UpdateSelfUserPassword_AllowsFirstTimeSetWithoutStoredPassword(t *testing.T) { + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + entityMock.On("IsEntityDeclarative", mock.Anything, mock.Anything).Return(false, nil).Maybe() + entityMock. + On("GetCredentialsByType", mock.Anything, svcTestUserID1, "password"). + Return([]entitypkg.StoredCredential{}, nil). + Once() + entityMock. + On("GetEntity", mock.Anything, svcTestUserID1). + Return(&providers.Entity{ + Category: providers.EntityCategoryUser, ID: svcTestUserID1, Type: "Person", + }, nil). + Once() + entityMock. + On("UpdateCredentials", mock.Anything, svcTestUserID1, mock.Anything). + Return(nil). + Once() + + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, "", json.RawMessage(`{"password":"n3wP@ss"}`)) + + require.Nil(t, err) + // Nothing to prove yet, so verification must not run. + entityMock.AssertNotCalled(t, "AuthenticateEntityByID", + mock.Anything, mock.Anything, mock.Anything) + entityMock.AssertNumberOfCalls(t, "UpdateCredentials", 1) +} + +func TestUserService_UpdateSelfUserPassword_RejectsNonPasswordCredentialTypes(t *testing.T) { + // UpdateUserCredentials accepts any schema-declared credential, so the self-service path has to + // narrow the payload itself. Proving the password must not authorize writing something else. + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, "0ldP@ssword!", json.RawMessage(`{"pin":"4321"}`)) + + require.NotNil(t, err) + require.Equal(t, ErrorInvalidCredential, *err) + entityMock.AssertNotCalled(t, "GetCredentialsByType", mock.Anything, mock.Anything, mock.Anything) + entityMock.AssertNotCalled(t, "UpdateCredentials", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUserService_UpdateSelfUserPassword_RejectsPasswordMixedWithAnotherCredentialType(t *testing.T) { + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, "0ldP@ssword!", + json.RawMessage(`{"password":"n3wP@ssword!","pin":"4321"}`)) + + require.NotNil(t, err) + require.Equal(t, ErrorInvalidCredential, *err) + entityMock.AssertNotCalled(t, "UpdateCredentials", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUserService_UpdateSelfUserPassword_RejectsNonPasswordCredentialWithNoProofSupplied(t *testing.T) { + // The first-time-set branch skips verification entirely. Without the payload restriction that + // branch would write any schema credential on the access token alone, which is the takeover + // path this endpoint exists to close. + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, "", json.RawMessage(`{"pin":"4321"}`)) + + require.NotNil(t, err) + require.Equal(t, ErrorInvalidCredential, *err) + entityMock.AssertNotCalled(t, "GetCredentialsByType", mock.Anything, mock.Anything, mock.Anything) + entityMock.AssertNotCalled(t, "UpdateCredentials", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUserService_UpdateSelfUserPassword_RejectsEmptyCredentialPayload(t *testing.T) { + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, "0ldP@ssword!", json.RawMessage(`{}`)) + + require.NotNil(t, err) + require.Equal(t, ErrorMissingCredentials, *err) + entityMock.AssertNotCalled(t, "GetCredentialsByType", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUserService_UpdateSelfUserPassword_ReturnsServerErrorWhenStoredCredentialReadFails(t *testing.T) { + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + entityMock. + On("GetCredentialsByType", mock.Anything, svcTestUserID1, "password"). + Return(nil, errors.New("store unavailable")). + Once() + + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, "0ldP@ss", json.RawMessage(`{"password":"n3wP@ss"}`)) + + require.NotNil(t, err) + require.Equal(t, tidcommon.InternalServerError, *err) + entityMock.AssertNotCalled(t, "UpdateCredentials", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUserService_UpdateSelfUserPassword_ReturnsNotFoundWhenVerificationReportsUnknownEntity(t *testing.T) { + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + entityMock. + On("GetCredentialsByType", mock.Anything, svcTestUserID1, "password"). + Return(storedPasswordCredential(), nil). + Once() + entityMock. + On("AuthenticateEntityByID", mock.Anything, svcTestUserID1, + map[string]interface{}{"password": "0ldP@ss"}). + Return(nil, entitypkg.ErrEntityNotFound). + Once() + + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, "0ldP@ss", json.RawMessage(`{"password":"n3wP@ss"}`)) + + require.NotNil(t, err) + require.Equal(t, ErrorUserNotFound, *err) + entityMock.AssertNotCalled(t, "UpdateCredentials", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUserService_UpdateSelfUserPassword_ReturnsServerErrorWhenVerificationFailsUnexpectedly(t *testing.T) { + // An unexpected verification failure must not fall through to the write. Anything other than a + // clean "wrong password" leaves the caller unproven. + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + entityMock. + On("GetCredentialsByType", mock.Anything, svcTestUserID1, "password"). + Return(storedPasswordCredential(), nil). + Once() + entityMock. + On("AuthenticateEntityByID", mock.Anything, svcTestUserID1, + map[string]interface{}{"password": "0ldP@ss"}). + Return(nil, errors.New("hash backend unavailable")). + Once() + + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, "0ldP@ss", json.RawMessage(`{"password":"n3wP@ss"}`)) + + require.NotNil(t, err) + require.Equal(t, tidcommon.InternalServerError, *err) + entityMock.AssertNotCalled(t, "UpdateCredentials", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUserService_UpdateSelfUserPassword_RejectsAbsentCredentialPayload(t *testing.T) { + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword(context.Background(), svcTestUserID1, "0ldP@ss", nil) + + require.NotNil(t, err) + require.Equal(t, ErrorMissingCredentials, *err) + entityMock.AssertNotCalled(t, "GetCredentialsByType", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUserService_UpdateSelfUserPassword_RejectsMalformedCredentialPayload(t *testing.T) { + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, "0ldP@ss", json.RawMessage(`{"password":`)) + + require.NotNil(t, err) + require.Equal(t, ErrorInvalidRequestFormat, *err) + entityMock.AssertNotCalled(t, "GetCredentialsByType", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUserService_UpdateSelfUserPassword_ReturnsNotFoundForUnknownUser(t *testing.T) { + entityMock := entitymock.NewEntityServiceInterfaceMock(t) + entityMock. + On("GetCredentialsByType", mock.Anything, svcTestUserID1, "password"). + Return(nil, entitypkg.ErrEntityNotFound). + Once() + + service := &userService{entityService: entityMock, authzService: newAllowAllAuthz(t)} + + err := service.UpdateSelfUserPassword( + context.Background(), svcTestUserID1, "0ldP@ss", json.RawMessage(`{"password":"n3wP@ss"}`)) + + require.NotNil(t, err) + require.Equal(t, ErrorUserNotFound, *err) +} diff --git a/backend/tests/mocks/usermock/UserServiceInterface_mock.go b/backend/tests/mocks/usermock/UserServiceInterface_mock.go index 9c53f4b849..29d3e9e450 100644 --- a/backend/tests/mocks/usermock/UserServiceInterface_mock.go +++ b/backend/tests/mocks/usermock/UserServiceInterface_mock.go @@ -827,6 +827,77 @@ func (_c *UserServiceInterfaceMock_SetDependencyRegistry_Call) RunAndReturn(run return _c } +// UpdateSelfUserPassword provides a mock function for the type UserServiceInterfaceMock +func (_mock *UserServiceInterfaceMock) UpdateSelfUserPassword(ctx context.Context, userID string, currentPassword string, credentials json.RawMessage) *common.ServiceError { + ret := _mock.Called(ctx, userID, currentPassword, credentials) + + if len(ret) == 0 { + panic("no return value specified for UpdateSelfUserPassword") + } + + var r0 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, json.RawMessage) *common.ServiceError); ok { + r0 = returnFunc(ctx, userID, currentPassword, credentials) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*common.ServiceError) + } + } + return r0 +} + +// UserServiceInterfaceMock_UpdateSelfUserPassword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateSelfUserPassword' +type UserServiceInterfaceMock_UpdateSelfUserPassword_Call struct { + *mock.Call +} + +// UpdateSelfUserPassword is a helper method to define mock.On call +// - ctx context.Context +// - userID string +// - currentPassword string +// - credentials json.RawMessage +func (_e *UserServiceInterfaceMock_Expecter) UpdateSelfUserPassword(ctx interface{}, userID interface{}, currentPassword interface{}, credentials interface{}) *UserServiceInterfaceMock_UpdateSelfUserPassword_Call { + return &UserServiceInterfaceMock_UpdateSelfUserPassword_Call{Call: _e.mock.On("UpdateSelfUserPassword", ctx, userID, currentPassword, credentials)} +} + +func (_c *UserServiceInterfaceMock_UpdateSelfUserPassword_Call) Run(run func(ctx context.Context, userID string, currentPassword string, credentials json.RawMessage)) *UserServiceInterfaceMock_UpdateSelfUserPassword_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 json.RawMessage + if args[3] != nil { + arg3 = args[3].(json.RawMessage) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *UserServiceInterfaceMock_UpdateSelfUserPassword_Call) Return(serviceError *common.ServiceError) *UserServiceInterfaceMock_UpdateSelfUserPassword_Call { + _c.Call.Return(serviceError) + return _c +} + +func (_c *UserServiceInterfaceMock_UpdateSelfUserPassword_Call) RunAndReturn(run func(ctx context.Context, userID string, currentPassword string, credentials json.RawMessage) *common.ServiceError) *UserServiceInterfaceMock_UpdateSelfUserPassword_Call { + _c.Call.Return(run) + return _c +} + // UpdateUser provides a mock function for the type UserServiceInterfaceMock func (_mock *UserServiceInterfaceMock) UpdateUser(ctx context.Context, userID string, user1 *providers.User) (*providers.User, *common.ServiceError) { ret := _mock.Called(ctx, userID, user1) diff --git a/docs/content/use-cases/b2c/try-it-out/profile-section.mdx b/docs/content/use-cases/b2c/try-it-out/profile-section.mdx index 865256a7d1..b28cc8b00b 100644 --- a/docs/content/use-cases/b2c/try-it-out/profile-section.mdx +++ b/docs/content/use-cases/b2c/try-it-out/profile-section.mdx @@ -24,7 +24,7 @@ Complete [Set Up Your Environment](../../build-environment) before starting this -In the redirect-based pattern, the consumer application renders the profile screen itself. Profile attributes are read from `/users/me` and ID token. Updates go through 's self-service endpoints: `/users/me` for attribute changes, `/users/me/meta` for schema metadata retrieve, and `/users/me/update-credentials` for password changes. These endpoints act on the signed-in user's own record, so no extra permissions are needed; the access token alone is enough. +In the redirect-based pattern, the consumer application renders the profile screen itself. Profile attributes are read from `/users/me` and the ID token. Updates go through 's self-service endpoints: `/users/me` for attribute changes, `/users/me/meta` for schema metadata retrieval, and `/users/me/update-credentials` for password changes. These endpoints act on the signed-in user's own record, so no extra permissions are needed and the access token alone is enough to read attributes and schema metadata. Changing a password requires the user's current password in the request body when the account already has a stored password. Accounts without an existing password can set one without `currentPassword`. The endpoint returns `403` with code `USR-1029` when a stored-password account omits or supplies an incorrect `currentPassword`. **Try the Use Case** diff --git a/samples/apps/vanilla-sample/src/services/userProfileService.ts b/samples/apps/vanilla-sample/src/services/userProfileService.ts index cff2d53868..6a0af8040e 100644 --- a/samples/apps/vanilla-sample/src/services/userProfileService.ts +++ b/samples/apps/vanilla-sample/src/services/userProfileService.ts @@ -82,7 +82,7 @@ export const updateCurrentUserProfile = async ( return normalizeUserProfile(await response.json() as Partial); }; -export const updateCurrentUserPassword = async (password: string): Promise => { +export const updateCurrentUserPassword = async (password: string, currentPassword: string): Promise => { const response = await fetch('/api/profile/password', { method: 'POST', headers: { @@ -90,6 +90,7 @@ export const updateCurrentUserPassword = async (password: string): Promise Accept: 'application/json', }, body: JSON.stringify({ + currentPassword, attributes: { password, }, diff --git a/samples/apps/vanilla-sample/src/views/ProfilePage.tsx b/samples/apps/vanilla-sample/src/views/ProfilePage.tsx index c8af4444e9..e0b3d1b5a0 100644 --- a/samples/apps/vanilla-sample/src/views/ProfilePage.tsx +++ b/samples/apps/vanilla-sample/src/views/ProfilePage.tsx @@ -101,6 +101,7 @@ const ProfilePage = () => { const [error, setError] = useState(''); const [successMessage, setSuccessMessage] = useState(''); const [passwordState, setPasswordState] = useState({ + currentPassword: '', newPassword: '', confirmPassword: '', }); @@ -157,7 +158,7 @@ const ProfilePage = () => { setSuccessMessage(''); }; - const handlePasswordChange = (key: 'newPassword' | 'confirmPassword', value: string) => { + const handlePasswordChange = (key: 'currentPassword' | 'newPassword' | 'confirmPassword', value: string) => { setPasswordState((prev) => ({ ...prev, [key]: value, @@ -238,6 +239,13 @@ const ProfilePage = () => { } const trimmedPassword = passwordState.newPassword.trim(); + const trimmedCurrentPassword = passwordState.currentPassword.trim(); + + if (!trimmedCurrentPassword) { + setError('Enter your current password.'); + setSuccessMessage(''); + return; + } if (!trimmedPassword) { setError('Enter a new password.'); @@ -256,8 +264,9 @@ const ProfilePage = () => { setSuccessMessage(''); try { - await updateCurrentUserPassword(trimmedPassword); + await updateCurrentUserPassword(trimmedPassword, trimmedCurrentPassword); setPasswordState({ + currentPassword: '', newPassword: '', confirmPassword: '', }); @@ -520,6 +529,17 @@ const ProfilePage = () => { }, }} > + + Current Password + handlePasswordChange('currentPassword', event.target.value)} + /> + New Password
+