From 5c92bf9fd321a2b6a17cee7aca26f5782a4a66d8 Mon Sep 17 00:00:00 2001 From: Mike Johanson Date: Mon, 12 Jan 2026 15:48:30 -0700 Subject: [PATCH] feat: adds support for randomly generated admin password on first run For a long time, we have used G@ppm0ym as a standard static default password. This PR removes the static password. The goal has been for users to always integrate their own authentication with role based auth. That said, we understand this doesn't always happen. As such, randomly generating a password on first run when no static password is provided will adhere to better security practices. Existing users should have the `adminPassword` and therefore be unaffected by this change. --- .env.example | 3 +- cmd/app/main.go | 137 ++++++++++++++++ cmd/app/main_test.go | 269 +++++++++++++++++++++++++++++++ config/config.go | 2 +- internal/mocks/storager_mocks.go | 26 +++ internal/usecase/devices/repo.go | 33 ++-- 6 files changed, 453 insertions(+), 17 deletions(-) create mode 100644 internal/mocks/storager_mocks.go diff --git a/.env.example b/.env.example index 1e378ab79..71b03d85a 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,8 @@ EA_PASSWORD= # Auth AUTH_DISABLED=false AUTH_ADMIN_USERNAME=standalone -AUTH_ADMIN_PASSWORD=G@ppm0ym +# AUTH_ADMIN_PASSWORD: If not set, a random password is generated and stored in the system keyring +AUTH_ADMIN_PASSWORD= AUTH_JWT_KEY=your_secret_jwt_key AUTH_JWT_EXPIRATION=24h AUTH_REDIRECTION_JWT_EXPIRATION=5m diff --git a/cmd/app/main.go b/cmd/app/main.go index 8d09b2323..a3cd109b1 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/rand" + "encoding/base64" "errors" "fmt" "log" @@ -26,6 +28,9 @@ var ( ErrSecretStoreTokenNotConfigured = errors.New("secret store token not configured") ) +// adminPasswordLength is the length of generated admin passwords. +const adminPasswordLength = 16 + // Function pointers for better testability. var ( initializeConfigFunc = config.NewConfig @@ -64,6 +69,7 @@ func main() { } handleEncryptionKey(cfg) + handleAdminPassword(cfg) handleDebugMode(cfg) runAppFunc(cfg) } @@ -300,6 +306,137 @@ func handleKeyNotFound(toolkitCrypto security.Crypto, _, _ security.Storager) st return toolkitCrypto.GenerateKey() } +// generateRandomPassword creates a cryptographically secure random password. +func generateRandomPassword(length int) (string, error) { + bytes := make([]byte, length) + + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + + return base64.URLEncoding.EncodeToString(bytes)[:length], nil +} + +// handleAdminPassword manages the admin password - loading from keyring or generating a new one. +func handleAdminPassword(cfg *config.Config) { + // If admin password is already provided via config/env, just use it + if cfg.AdminPassword != "" { + log.Println("Admin password loaded from configuration") + + return + } + + // Try to initialize secret store client for password retrieval + remoteStorage, err := handleSecretsConfig(cfg) + if err != nil { + remoteStorage = nil + } + + // Try remote storage first + if done := tryRemoteAdminPassword(cfg, remoteStorage); done { + return + } + + // Try local keyring storage + localStorage := security.NewKeyRingStorage("device-management-toolkit") + + if done := tryLocalAdminPassword(cfg, localStorage, remoteStorage); done { + return + } + + // Password not found anywhere, generate a new one + password, err := generateRandomPassword(adminPasswordLength) + if err != nil { + log.Fatalf("Failed to generate admin password: %v", err) + } + + cfg.AdminPassword = password + + if err := saveAdminPassword(password, remoteStorage, localStorage); err != nil { + log.Printf("Warning: Failed to save admin password: %v", err) + } + + // Output the generated password so the user knows what to use + log.Printf("\033[33m========================================\033[0m") + log.Printf("\033[33mGenerated Admin Password: %s\033[0m", password) + log.Printf("\033[33mThis password has been saved to your system keyring.\033[0m") + log.Printf("\033[33m========================================\033[0m") +} + +// tryRemoteAdminPassword attempts to retrieve the admin password from remote storage. +func tryRemoteAdminPassword(cfg *config.Config, remoteStorage security.Storager) bool { + if remoteStorage == nil { + return false + } + + password, err := remoteStorage.GetKeyValue("admin-password") + if err == nil && password != "" { + cfg.AdminPassword = password + + log.Println("Admin password loaded from secret store") + + return true + } + + return false +} + +// tryLocalAdminPassword attempts to retrieve the admin password from local keyring. +func tryLocalAdminPassword(cfg *config.Config, localStorage, remoteStorage security.Storager) bool { + password, err := localStorage.GetKeyValue("admin-password") + if err == nil && password != "" { + cfg.AdminPassword = password + + log.Println("Admin password loaded from local keyring") + syncAdminPasswordToRemote(password, remoteStorage) + + return true + } + + // Check for unexpected errors + if err != nil && !errors.Is(err, security.ErrKeyNotFound) { + log.Printf("Warning: Failed to read admin password from keyring: %v", err) + } + + return false +} + +// syncAdminPasswordToRemote syncs the admin password to remote storage if available. +func syncAdminPasswordToRemote(password string, remoteStorage security.Storager) { + if remoteStorage == nil { + return + } + + if err := remoteStorage.SetKeyValue("admin-password", password); err != nil { + log.Printf("Warning: Failed to sync admin password to secret store: %v", err) + } else { + log.Println("Admin password synced to secret store") + } +} + +func saveAdminPassword(password string, remoteStorage, localStorage security.Storager) error { + if remoteStorage != nil { + err := remoteStorage.SetKeyValue("admin-password", password) + if err == nil { + log.Println("Admin password saved to secret store") + + return nil + } + + return err + } + + err := localStorage.SetKeyValue("admin-password", password) + if err == nil { + log.Println("Admin password saved to local keyring") + + return nil + } + + return err +} + // CommandExecutor is an interface to allow for mocking exec.Command in tests. type CommandExecutor interface { Execute(name string, arg ...string) error diff --git a/cmd/app/main_test.go b/cmd/app/main_test.go index ce75169d5..d2b85924a 100644 --- a/cmd/app/main_test.go +++ b/cmd/app/main_test.go @@ -3,16 +3,19 @@ package main import ( "crypto/rsa" "crypto/x509" + "errors" "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/security" "github.com/device-management-toolkit/console/config" "github.com/device-management-toolkit/console/internal/certificates" + "github.com/device-management-toolkit/console/internal/mocks" "github.com/device-management-toolkit/console/internal/usecase" "github.com/device-management-toolkit/console/pkg/logger" ) @@ -150,3 +153,269 @@ func TestHandleOpenAPIGeneration_GenerateFails(t *testing.T) { mockGen.AssertExpectations(t) } + +// TestGenerateRandomPassword tests the password generation function. +func TestGenerateRandomPassword(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + length int + }{ + {"length 8", 8}, + {"length 16", 16}, + {"length 32", 32}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + password, err := generateRandomPassword(tc.length) + require.NoError(t, err) + assert.Len(t, password, tc.length) + }) + } +} + +// TestGenerateRandomPassword_Uniqueness ensures generated passwords are unique. +func TestGenerateRandomPassword_Uniqueness(t *testing.T) { + t.Parallel() + + passwords := make(map[string]bool) + + for i := 0; i < 100; i++ { + password, err := generateRandomPassword(16) + require.NoError(t, err) + assert.False(t, passwords[password], "generated duplicate password") + + passwords[password] = true + } +} + +// TestTryRemoteAdminPassword_NilStorage tests when remote storage is nil. +func TestTryRemoteAdminPassword_NilStorage(t *testing.T) { + t.Parallel() + + cfg := &config.Config{} + result := tryRemoteAdminPassword(cfg, nil) + + assert.False(t, result) + assert.Empty(t, cfg.AdminPassword) +} + +// TestTryRemoteAdminPassword_Success tests successful password retrieval from remote. +func TestTryRemoteAdminPassword_Success(t *testing.T) { + t.Parallel() + + mockStorage := new(mocks.MockStorager) + mockStorage.On("GetKeyValue", "admin-password").Return("remote-password", nil) + + cfg := &config.Config{} + result := tryRemoteAdminPassword(cfg, mockStorage) + + assert.True(t, result) + assert.Equal(t, "remote-password", cfg.AdminPassword) + mockStorage.AssertExpectations(t) +} + +// TestTryRemoteAdminPassword_NotFound tests when password is not in remote storage. +func TestTryRemoteAdminPassword_NotFound(t *testing.T) { + t.Parallel() + + mockStorage := new(mocks.MockStorager) + mockStorage.On("GetKeyValue", "admin-password").Return("", security.ErrKeyNotFound) + + cfg := &config.Config{} + result := tryRemoteAdminPassword(cfg, mockStorage) + + assert.False(t, result) + assert.Empty(t, cfg.AdminPassword) + mockStorage.AssertExpectations(t) +} + +// TestTryRemoteAdminPassword_EmptyValue tests when remote returns empty value. +func TestTryRemoteAdminPassword_EmptyValue(t *testing.T) { + t.Parallel() + + mockStorage := new(mocks.MockStorager) + mockStorage.On("GetKeyValue", "admin-password").Return("", nil) + + cfg := &config.Config{} + result := tryRemoteAdminPassword(cfg, mockStorage) + + assert.False(t, result) + assert.Empty(t, cfg.AdminPassword) + mockStorage.AssertExpectations(t) +} + +// TestTryLocalAdminPassword_Success tests successful password retrieval from keyring. +func TestTryLocalAdminPassword_Success(t *testing.T) { + t.Parallel() + + mockLocal := new(mocks.MockStorager) + mockLocal.On("GetKeyValue", "admin-password").Return("local-password", nil) + + cfg := &config.Config{} + result := tryLocalAdminPassword(cfg, mockLocal, nil) + + assert.True(t, result) + assert.Equal(t, "local-password", cfg.AdminPassword) + mockLocal.AssertExpectations(t) +} + +// TestTryLocalAdminPassword_SuccessWithSync tests retrieval from local with sync to remote. +func TestTryLocalAdminPassword_SuccessWithSync(t *testing.T) { + t.Parallel() + + mockLocal := new(mocks.MockStorager) + mockRemote := new(mocks.MockStorager) + + mockLocal.On("GetKeyValue", "admin-password").Return("local-password", nil) + mockRemote.On("SetKeyValue", "admin-password", "local-password").Return(nil) + + cfg := &config.Config{} + result := tryLocalAdminPassword(cfg, mockLocal, mockRemote) + + assert.True(t, result) + assert.Equal(t, "local-password", cfg.AdminPassword) + mockLocal.AssertExpectations(t) + mockRemote.AssertExpectations(t) +} + +// TestTryLocalAdminPassword_NotFound tests when password is not in local keyring. +func TestTryLocalAdminPassword_NotFound(t *testing.T) { + t.Parallel() + + mockLocal := new(mocks.MockStorager) + mockLocal.On("GetKeyValue", "admin-password").Return("", security.ErrKeyNotFound) + + cfg := &config.Config{} + result := tryLocalAdminPassword(cfg, mockLocal, nil) + + assert.False(t, result) + assert.Empty(t, cfg.AdminPassword) + mockLocal.AssertExpectations(t) +} + +// TestTryLocalAdminPassword_EmptyValue tests when local returns empty value. +func TestTryLocalAdminPassword_EmptyValue(t *testing.T) { + t.Parallel() + + mockLocal := new(mocks.MockStorager) + mockLocal.On("GetKeyValue", "admin-password").Return("", nil) + + cfg := &config.Config{} + result := tryLocalAdminPassword(cfg, mockLocal, nil) + + assert.False(t, result) + assert.Empty(t, cfg.AdminPassword) + mockLocal.AssertExpectations(t) +} + +// TestSyncAdminPasswordToRemote_NilStorage tests sync when remote is nil. +func TestSyncAdminPasswordToRemote_NilStorage(t *testing.T) { + t.Parallel() + + // Should not panic when remote is nil + syncAdminPasswordToRemote("password", nil) +} + +// TestSyncAdminPasswordToRemote_Success tests successful sync to remote. +func TestSyncAdminPasswordToRemote_Success(t *testing.T) { + t.Parallel() + + mockRemote := new(mocks.MockStorager) + mockRemote.On("SetKeyValue", "admin-password", "test-password").Return(nil) + + syncAdminPasswordToRemote("test-password", mockRemote) + + mockRemote.AssertExpectations(t) +} + +// TestSyncAdminPasswordToRemote_Error tests sync failure handling. +func TestSyncAdminPasswordToRemote_Error(t *testing.T) { + t.Parallel() + + mockRemote := new(mocks.MockStorager) + mockRemote.On("SetKeyValue", "admin-password", "test-password").Return(errors.New("sync failed")) + + // Should not panic on error, just log warning + syncAdminPasswordToRemote("test-password", mockRemote) + + mockRemote.AssertExpectations(t) +} + +// TestSaveAdminPassword_RemoteSuccess tests saving to remote storage. +func TestSaveAdminPassword_RemoteSuccess(t *testing.T) { + t.Parallel() + + mockRemote := new(mocks.MockStorager) + mockLocal := new(mocks.MockStorager) + + mockRemote.On("SetKeyValue", "admin-password", "test-password").Return(nil) + + err := saveAdminPassword("test-password", mockRemote, mockLocal) + + assert.NoError(t, err) + mockRemote.AssertExpectations(t) + mockLocal.AssertNotCalled(t, "SetKeyValue") +} + +// TestSaveAdminPassword_RemoteError tests fallback behavior is not attempted when remote fails. +func TestSaveAdminPassword_RemoteError(t *testing.T) { + t.Parallel() + + mockRemote := new(mocks.MockStorager) + mockLocal := new(mocks.MockStorager) + + mockRemote.On("SetKeyValue", "admin-password", "test-password").Return(errors.New("remote error")) + + err := saveAdminPassword("test-password", mockRemote, mockLocal) + + assert.Error(t, err) + mockRemote.AssertExpectations(t) + // Local should not be called when remote is configured but fails + mockLocal.AssertNotCalled(t, "SetKeyValue") +} + +// TestSaveAdminPassword_LocalSuccess tests saving to local keyring when remote is nil. +func TestSaveAdminPassword_LocalSuccess(t *testing.T) { + t.Parallel() + + mockLocal := new(mocks.MockStorager) + mockLocal.On("SetKeyValue", "admin-password", "test-password").Return(nil) + + err := saveAdminPassword("test-password", nil, mockLocal) + + assert.NoError(t, err) + mockLocal.AssertExpectations(t) +} + +// TestSaveAdminPassword_LocalError tests local save failure. +func TestSaveAdminPassword_LocalError(t *testing.T) { + t.Parallel() + + mockLocal := new(mocks.MockStorager) + mockLocal.On("SetKeyValue", "admin-password", "test-password").Return(errors.New("local error")) + + err := saveAdminPassword("test-password", nil, mockLocal) + + assert.Error(t, err) + mockLocal.AssertExpectations(t) +} + +// TestHandleAdminPassword_AlreadyConfigured tests when password is already set. +func TestHandleAdminPassword_AlreadyConfigured(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + Auth: config.Auth{ + AdminPassword: "already-set", + }, + } + + handleAdminPassword(cfg) + + assert.Equal(t, "already-set", cfg.AdminPassword) +} diff --git a/config/config.go b/config/config.go index bf187b37d..77b4ab6ec 100644 --- a/config/config.go +++ b/config/config.go @@ -177,7 +177,7 @@ func defaultConfig() *Config { }, Auth: Auth{ AdminUsername: "standalone", - AdminPassword: "G@ppm0ym", + AdminPassword: "", // Generated and stored in keyring if not provided JWTKey: "your_secret_jwt_key", JWTExpiration: 24 * time.Hour, RedirectionJWTExpiration: 5 * time.Minute, diff --git a/internal/mocks/storager_mocks.go b/internal/mocks/storager_mocks.go new file mode 100644 index 000000000..82d8363b5 --- /dev/null +++ b/internal/mocks/storager_mocks.go @@ -0,0 +1,26 @@ +package mocks + +import "github.com/stretchr/testify/mock" + +// MockStorager implements security.Storager for testing. +type MockStorager struct { + mock.Mock +} + +func (m *MockStorager) GetKeyValue(key string) (string, error) { + args := m.Called(key) + + return args.String(0), args.Error(1) +} + +func (m *MockStorager) SetKeyValue(key, value string) error { + args := m.Called(key, value) + + return args.Error(0) +} + +func (m *MockStorager) DeleteKeyValue(key string) error { + args := m.Called(key) + + return args.Error(0) +} diff --git a/internal/usecase/devices/repo.go b/internal/usecase/devices/repo.go index eefa0723d..cb9124389 100644 --- a/internal/usecase/devices/repo.go +++ b/internal/usecase/devices/repo.go @@ -73,24 +73,27 @@ func (uc *UseCase) GetByID(ctx context.Context, guid, tenantID string, includeSe } d2 := uc.entityToDTO(data) - if includeSecrets { - d2.Password, err = uc.safeRequirements.Decrypt(data.Password) - if err != nil { - return nil, ErrDeviceUseCase.Wrap("GetByID", "uc.safeRequirements.Decrypt Password", err) - } - if data.MPSPassword != nil { - d2.MPSPassword, err = uc.safeRequirements.Decrypt(*data.MPSPassword) - if err != nil { - return nil, ErrDeviceUseCase.Wrap("GetByID", "uc.safeRequirements.Decrypt MPSPassword", err) - } + if !includeSecrets { + return d2, nil + } + + d2.Password, err = uc.safeRequirements.Decrypt(data.Password) + if err != nil { + return nil, ErrDeviceUseCase.Wrap("GetByID", "uc.safeRequirements.Decrypt Password", err) + } + + if data.MPSPassword != nil { + d2.MPSPassword, err = uc.safeRequirements.Decrypt(*data.MPSPassword) + if err != nil { + return nil, ErrDeviceUseCase.Wrap("GetByID", "uc.safeRequirements.Decrypt MPSPassword", err) } + } - if data.MEBXPassword != nil { - d2.MEBXPassword, err = uc.safeRequirements.Decrypt(*data.MEBXPassword) - if err != nil { - return nil, ErrDeviceUseCase.Wrap("GetByID", "uc.safeRequirements.Decrypt MEBXPassword", err) - } + if data.MEBXPassword != nil { + d2.MEBXPassword, err = uc.safeRequirements.Decrypt(*data.MEBXPassword) + if err != nil { + return nil, ErrDeviceUseCase.Wrap("GetByID", "uc.safeRequirements.Decrypt MEBXPassword", err) } }