Local user password change (embedded IdP) (#5132)

This commit is contained in:
Misha Bragin
2026-01-20 14:16:42 +01:00
committed by GitHub
parent 50da5074e7
commit a0b0b664b6
12 changed files with 754 additions and 320 deletions

View File

@@ -32,6 +32,7 @@ type Manager interface {
CreateUser(ctx context.Context, accountID, initiatorUserID string, key *types.UserInfo) (*types.UserInfo, error)
DeleteUser(ctx context.Context, accountID, initiatorUserID string, targetUserID string) error
DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error
UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error
InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error
ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error)
RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error

View File

@@ -195,7 +195,9 @@ const (
DNSRecordUpdated Activity = 100
DNSRecordDeleted Activity = 101
JobCreatedByUser Activity = 102
JobCreatedByUser Activity = 102
UserPasswordChanged Activity = 103
AccountDeleted Activity = 99999
)
@@ -323,6 +325,8 @@ var activityMap = map[Activity]Code{
DNSRecordDeleted: {"DNS zone record deleted", "dns.zone.record.delete"},
JobCreatedByUser: {"Create Job for peer", "peer.job.create"},
UserPasswordChanged: {"User password changed", "user.password.change"},
}
// StringCode returns a string code of the activity

View File

@@ -33,6 +33,7 @@ func AddEndpoints(accountManager account.Manager, router *mux.Router) {
router.HandleFunc("/users/{userId}/invite", userHandler.inviteUser).Methods("POST", "OPTIONS")
router.HandleFunc("/users/{userId}/approve", userHandler.approveUser).Methods("POST", "OPTIONS")
router.HandleFunc("/users/{userId}/reject", userHandler.rejectUser).Methods("DELETE", "OPTIONS")
router.HandleFunc("/users/{userId}/password", userHandler.changePassword).Methods("PUT", "OPTIONS")
addUsersTokensEndpoint(accountManager, router)
}
@@ -410,3 +411,46 @@ func (h *handler) rejectUser(w http.ResponseWriter, r *http.Request) {
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
}
// passwordChangeRequest represents the request body for password change
type passwordChangeRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
// changePassword is a PUT request to change user's password.
// Only available when embedded IDP is enabled.
// Users can only change their own password.
func (h *handler) changePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
return
}
vars := mux.Vars(r)
targetUserID := vars["userId"]
if len(targetUserID) == 0 {
util.WriteErrorResponse("invalid user ID", http.StatusBadRequest, w)
return
}
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
var req passwordChangeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
return
}
err = h.accountManager.UpdateUserPassword(r.Context(), userAuth.AccountId, userAuth.UserId, targetUserID, req.OldPassword, req.NewPassword)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
}

View File

@@ -856,3 +856,118 @@ func TestRejectUserEndpoint(t *testing.T) {
})
}
}
func TestChangePasswordEndpoint(t *testing.T) {
tt := []struct {
name string
expectedStatus int
requestBody string
targetUserID string
currentUserID string
mockError error
expectMockNotCalled bool
}{
{
name: "successful password change",
expectedStatus: http.StatusOK,
requestBody: `{"old_password": "OldPass123!", "new_password": "NewPass456!"}`,
targetUserID: existingUserID,
currentUserID: existingUserID,
mockError: nil,
},
{
name: "missing old password",
expectedStatus: http.StatusUnprocessableEntity,
requestBody: `{"new_password": "NewPass456!"}`,
targetUserID: existingUserID,
currentUserID: existingUserID,
mockError: status.Errorf(status.InvalidArgument, "old password is required"),
},
{
name: "missing new password",
expectedStatus: http.StatusUnprocessableEntity,
requestBody: `{"old_password": "OldPass123!"}`,
targetUserID: existingUserID,
currentUserID: existingUserID,
mockError: status.Errorf(status.InvalidArgument, "new password is required"),
},
{
name: "wrong old password",
expectedStatus: http.StatusUnprocessableEntity,
requestBody: `{"old_password": "WrongPass!", "new_password": "NewPass456!"}`,
targetUserID: existingUserID,
currentUserID: existingUserID,
mockError: status.Errorf(status.InvalidArgument, "invalid password"),
},
{
name: "embedded IDP not enabled",
expectedStatus: http.StatusPreconditionFailed,
requestBody: `{"old_password": "OldPass123!", "new_password": "NewPass456!"}`,
targetUserID: existingUserID,
currentUserID: existingUserID,
mockError: status.Errorf(status.PreconditionFailed, "password change is only available with embedded identity provider"),
},
{
name: "invalid JSON request",
expectedStatus: http.StatusBadRequest,
requestBody: `{invalid json}`,
targetUserID: existingUserID,
currentUserID: existingUserID,
expectMockNotCalled: true,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
mockCalled := false
am := &mock_server.MockAccountManager{}
am.UpdateUserPasswordFunc = func(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error {
mockCalled = true
return tc.mockError
}
handler := newHandler(am)
router := mux.NewRouter()
router.HandleFunc("/users/{userId}/password", handler.changePassword).Methods("PUT")
reqPath := "/users/" + tc.targetUserID + "/password"
req, err := http.NewRequest("PUT", reqPath, bytes.NewBufferString(tc.requestBody))
require.NoError(t, err)
userAuth := auth.UserAuth{
AccountId: existingAccountID,
UserId: tc.currentUserID,
}
ctx := nbcontext.SetUserAuthInContext(req.Context(), userAuth)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
assert.Equal(t, tc.expectedStatus, rr.Code)
if tc.expectMockNotCalled {
assert.False(t, mockCalled, "mock should not have been called")
}
})
}
}
func TestChangePasswordEndpoint_WrongMethod(t *testing.T) {
am := &mock_server.MockAccountManager{}
handler := newHandler(am)
req, err := http.NewRequest("POST", "/users/test-user/password", bytes.NewBufferString(`{}`))
require.NoError(t, err)
userAuth := auth.UserAuth{
AccountId: existingAccountID,
UserId: existingUserID,
}
req = nbcontext.SetUserAuthInRequest(req, userAuth)
rr := httptest.NewRecorder()
handler.changePassword(rr, req)
assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
}

View File

@@ -400,7 +400,6 @@ func (m *EmbeddedIdPManager) CreateUserWithPassword(ctx context.Context, email,
// InviteUserByID resends an invitation to a user.
func (m *EmbeddedIdPManager) InviteUserByID(ctx context.Context, userID string) error {
// TODO: implement
return fmt.Errorf("not implemented")
}
@@ -432,6 +431,33 @@ func (m *EmbeddedIdPManager) DeleteUser(ctx context.Context, userID string) erro
return nil
}
// UpdateUserPassword updates the password for a user in the embedded IdP.
// It verifies that the current user is changing their own password and
// validates the current password before updating to the new password.
func (m *EmbeddedIdPManager) UpdateUserPassword(ctx context.Context, currentUserID, targetUserID string, oldPassword, newPassword string) error {
// Verify the user is changing their own password
if currentUserID != targetUserID {
return fmt.Errorf("users can only change their own password")
}
// Verify the new password is different from the old password
if oldPassword == newPassword {
return fmt.Errorf("new password must be different from current password")
}
err := m.provider.UpdateUserPassword(ctx, targetUserID, oldPassword, newPassword)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return err
}
log.WithContext(ctx).Debugf("updated password for user %s in embedded IdP", targetUserID)
return nil
}
// CreateConnector creates a new identity provider connector in Dex.
// Returns the created connector config with the redirect URL populated.
func (m *EmbeddedIdPManager) CreateConnector(ctx context.Context, cfg *dex.ConnectorConfig) (*dex.ConnectorConfig, error) {
@@ -449,15 +475,8 @@ func (m *EmbeddedIdPManager) ListConnectors(ctx context.Context) ([]*dex.Connect
}
// UpdateConnector updates an existing identity provider connector.
// Field preservation for partial updates is handled by Provider.UpdateConnector.
func (m *EmbeddedIdPManager) UpdateConnector(ctx context.Context, cfg *dex.ConnectorConfig) error {
// Preserve existing secret if not provided in update
if cfg.ClientSecret == "" {
existing, err := m.provider.GetConnector(ctx, cfg.ID)
if err != nil {
return fmt.Errorf("failed to get existing connector: %w", err)
}
cfg.ClientSecret = existing.ClientSecret
}
return m.provider.UpdateConnector(ctx, cfg)
}

View File

@@ -248,6 +248,71 @@ func TestEmbeddedIdPManager_UserIDFormat_MatchesJWT(t *testing.T) {
t.Logf(" Connector: %s", connectorID)
}
func TestEmbeddedIdPManager_UpdateUserPassword(t *testing.T) {
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
require.NoError(t, err)
defer func() { _ = manager.Stop(ctx) }()
// Create a user with a known password
email := "password-test@example.com"
name := "Password Test User"
initialPassword := "InitialPass123!"
userData, err := manager.CreateUserWithPassword(ctx, email, initialPassword, name)
require.NoError(t, err)
require.NotNil(t, userData)
userID := userData.ID
t.Run("successful password change", func(t *testing.T) {
newPassword := "NewSecurePass456!"
err := manager.UpdateUserPassword(ctx, userID, userID, initialPassword, newPassword)
require.NoError(t, err)
// Verify the new password works by changing it again
anotherPassword := "AnotherPass789!"
err = manager.UpdateUserPassword(ctx, userID, userID, newPassword, anotherPassword)
require.NoError(t, err)
})
t.Run("wrong old password", func(t *testing.T) {
err := manager.UpdateUserPassword(ctx, userID, userID, "wrongpassword", "NewPass123!")
require.Error(t, err)
assert.Contains(t, err.Error(), "current password is incorrect")
})
t.Run("cannot change other user password", func(t *testing.T) {
otherUserID := "other-user-id"
err := manager.UpdateUserPassword(ctx, userID, otherUserID, "oldpass", "newpass")
require.Error(t, err)
assert.Contains(t, err.Error(), "users can only change their own password")
})
t.Run("same password rejected", func(t *testing.T) {
samePassword := "SamePass123!"
err := manager.UpdateUserPassword(ctx, userID, userID, samePassword, samePassword)
require.Error(t, err)
assert.Contains(t, err.Error(), "new password must be different")
})
}
func TestEmbeddedIdPManager_GetLocalKeysLocation(t *testing.T) {
ctx := context.Background()

View File

@@ -74,6 +74,7 @@ type MockAccountManager struct {
SaveOrAddUsersFunc func(ctx context.Context, accountID, initiatorUserID string, update []*types.User, addIfNotExists bool) ([]*types.UserInfo, error)
DeleteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error
DeleteRegularUsersFunc func(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error
UpdateUserPasswordFunc func(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error
CreatePATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error)
DeletePATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenID string) error
GetPATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenID string) (*types.PersonalAccessToken, error)
@@ -135,9 +136,9 @@ type MockAccountManager struct {
CreateIdentityProviderFunc func(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
UpdateIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
DeleteIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string) error
CreatePeerJobFunc func(ctx context.Context, accountID, peerID, userID string, job *types.Job) error
GetAllPeerJobsFunc func(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error)
GetPeerJobByIDFunc func(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error)
CreatePeerJobFunc func(ctx context.Context, accountID, peerID, userID string, job *types.Job) error
GetAllPeerJobsFunc func(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error)
GetPeerJobByIDFunc func(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error)
}
func (am *MockAccountManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error {
@@ -635,6 +636,14 @@ func (am *MockAccountManager) DeleteRegularUsers(ctx context.Context, accountID,
return status.Errorf(codes.Unimplemented, "method DeleteRegularUsers is not implemented")
}
// UpdateUserPassword mocks UpdateUserPassword of the AccountManager interface
func (am *MockAccountManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error {
if am.UpdateUserPasswordFunc != nil {
return am.UpdateUserPasswordFunc(ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword)
}
return status.Errorf(codes.Unimplemented, "method UpdateUserPassword is not implemented")
}
func (am *MockAccountManager) InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error {
if am.InviteUserFunc != nil {
return am.InviteUserFunc(ctx, accountID, initiatorUserID, targetUserID)

View File

@@ -249,6 +249,37 @@ func (am *DefaultAccountManager) ListUsers(ctx context.Context, accountID string
return am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
}
// UpdateUserPassword updates the password for a user in the embedded IdP.
// This is only available when the embedded IdP is enabled.
// Users can only change their own password.
func (am *DefaultAccountManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error {
if !IsEmbeddedIdp(am.idpManager) {
return status.Errorf(status.PreconditionFailed, "password change is only available with embedded identity provider")
}
if oldPassword == "" {
return status.Errorf(status.InvalidArgument, "old password is required")
}
if newPassword == "" {
return status.Errorf(status.InvalidArgument, "new password is required")
}
embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager)
if !ok {
return status.Errorf(status.Internal, "failed to get embedded IdP manager")
}
err := embeddedIdp.UpdateUserPassword(ctx, currentUserID, targetUserID, oldPassword, newPassword)
if err != nil {
return status.Errorf(status.InvalidArgument, "failed to update password: %v", err)
}
am.StoreEvent(ctx, currentUserID, targetUserID, accountID, activity.UserPasswordChanged, nil)
return nil
}
func (am *DefaultAccountManager) deleteServiceUser(ctx context.Context, accountID string, initiatorUserID string, targetUser *types.User) error {
if err := am.Store.DeleteUser(ctx, accountID, targetUser.Id); err != nil {
return err
@@ -806,7 +837,20 @@ func (am *DefaultAccountManager) getUserInfo(ctx context.Context, user *types.Us
}
return user.ToUserInfo(userData)
}
return user.ToUserInfo(nil)
userInfo, err := user.ToUserInfo(nil)
if err != nil {
return nil, err
}
// For embedded IDP users, extract the IdPID (connector ID) from the encoded user ID
if IsEmbeddedIdp(am.idpManager) && !user.IsServiceUser {
if _, connectorID, decodeErr := dex.DecodeDexUserID(user.Id); decodeErr == nil && connectorID != "" {
userInfo.IdPID = connectorID
}
}
return userInfo, nil
}
// validateUserUpdate validates the update operation for a user.