diff --git a/.env.example b/.env.example index 1e378ab7..71b03d85 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 8d09b232..a3cd109b 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 ce75169d..d2b85924 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 bf187b37..77b4ab6e 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 00000000..82d8363b --- /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 eefa0723..cb912438 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) } }