diff --git a/internal/api/auth_env_path.go b/internal/api/auth_env_path.go index 47f3acb62..51ef7bc81 100644 --- a/internal/api/auth_env_path.go +++ b/internal/api/auth_env_path.go @@ -1,6 +1,7 @@ package api import ( + "errors" "fmt" "os" "path/filepath" @@ -51,3 +52,56 @@ func removeAuthEnvFiles(configPath string, dataPath string) error { } return lastErr } + +type authEnvFileSnapshot struct { + path string + data []byte + mode os.FileMode + exists bool +} + +func snapshotAuthEnvFiles(configPath string, dataPath string) ([]authEnvFileSnapshot, error) { + paths := resolveAuthEnvWritePaths(configPath, dataPath) + snapshots := make([]authEnvFileSnapshot, 0, len(paths)) + for _, path := range paths { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + snapshots = append(snapshots, authEnvFileSnapshot{path: path}) + continue + } + if err != nil { + return nil, fmt.Errorf("read auth environment file %s: %w", path, err) + } + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat auth environment file %s: %w", path, err) + } + snapshots = append(snapshots, authEnvFileSnapshot{ + path: path, + data: data, + mode: info.Mode().Perm(), + exists: true, + }) + } + return snapshots, nil +} + +func restoreAuthEnvFiles(snapshots []authEnvFileSnapshot) error { + var restoreErr error + for _, snapshot := range snapshots { + if !snapshot.exists { + if err := os.Remove(snapshot.path); err != nil && !errors.Is(err, os.ErrNotExist) { + restoreErr = errors.Join(restoreErr, fmt.Errorf("remove newly created auth environment file %s: %w", snapshot.path, err)) + } + continue + } + if err := os.MkdirAll(filepath.Dir(snapshot.path), 0o755); err != nil { + restoreErr = errors.Join(restoreErr, fmt.Errorf("recreate auth environment directory for %s: %w", snapshot.path, err)) + continue + } + if err := os.WriteFile(snapshot.path, snapshot.data, snapshot.mode); err != nil { + restoreErr = errors.Join(restoreErr, fmt.Errorf("restore auth environment file %s: %w", snapshot.path, err)) + } + } + return restoreErr +} diff --git a/internal/api/security_first_run_reset.go b/internal/api/security_first_run_reset.go index 7db9a2986..69e12c7d3 100644 --- a/internal/api/security_first_run_reset.go +++ b/internal/api/security_first_run_reset.go @@ -36,8 +36,58 @@ func (r *Router) handleResetFirstRunSecurity(w http.ResponseWriter, req *http.Re return } + // Prepare the recovery credential before changing any active authentication + // state. A failure here must leave the caller's current credentials usable so + // they can repair the data path and retry the reset. + token, bootstrapCreated, path, err := loadOrCreateBootstrapToken(r.config.DataPath) + if err != nil { + log.Error().Err(err).Msg("Failed to recreate bootstrap token during first-run reset") + http.Error(w, "Failed to recreate bootstrap token", http.StatusInternalServerError) + return + } + rollbackBootstrap := func() { + if !bootstrapCreated { + return + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Error().Err(err).Str("token_path", path).Msg("Failed to roll back bootstrap token after first-run reset failure") + } + } + + // Remove restart-time password configuration before committing token + // revocation. Runtime authentication remains unchanged until both durable + // operations succeed. + authEnvSnapshots, err := snapshotAuthEnvFiles(r.config.ConfigPath, r.config.DataPath) + if err != nil { + rollbackBootstrap() + log.Warn().Err(err).Msg("Failed to snapshot auth env files during first-run reset") + http.Error(w, "Failed to read persisted auth configuration", http.StatusInternalServerError) + return + } + if err := removeAuthEnvFiles(r.config.ConfigPath, r.config.DataPath); err != nil { + rollbackBootstrap() + if restoreErr := restoreAuthEnvFiles(authEnvSnapshots); restoreErr != nil { + log.Error().Err(restoreErr).Msg("Failed to restore auth env files after first-run reset failure") + } + log.Warn().Err(err).Msg("Failed to remove auth env files during first-run reset") + http.Error(w, "Failed to remove persisted auth configuration", http.StatusInternalServerError) + return + } + config.Mu.Lock() previousAuthUser := strings.TrimSpace(r.config.AuthUser) + if r.persistence != nil { + if err := r.persistence.SaveAPITokens([]config.APITokenRecord{}); err != nil { + config.Mu.Unlock() + rollbackBootstrap() + if restoreErr := restoreAuthEnvFiles(authEnvSnapshots); restoreErr != nil { + log.Error().Err(restoreErr).Msg("Failed to restore auth env files after API token reset failure") + } + log.Warn().Err(err).Msg("Failed to clear persisted API tokens during first-run reset") + http.Error(w, "Failed to clear persisted API tokens", http.StatusInternalServerError) + return + } + } r.config.AuthUser = "" r.config.AuthPass = "" r.config.APIToken = "" @@ -56,27 +106,6 @@ func (r *Router) handleResetFirstRunSecurity(w http.ResponseWriter, req *http.Re InvalidateUserSessions(previousAuthUser) } r.clearSession(w, req) - r.clearBootstrapToken() - - if err := removeAuthEnvFiles(r.config.ConfigPath, r.config.DataPath); err != nil { - log.Warn().Err(err).Msg("Failed to remove auth env files during first-run reset") - http.Error(w, "Failed to remove persisted auth configuration", http.StatusInternalServerError) - return - } - if r.persistence != nil { - if err := r.persistence.SaveAPITokens([]config.APITokenRecord{}); err != nil { - log.Warn().Err(err).Msg("Failed to clear persisted API tokens during first-run reset") - http.Error(w, "Failed to clear persisted API tokens", http.StatusInternalServerError) - return - } - } - - token, _, path, err := loadOrCreateBootstrapToken(r.config.DataPath) - if err != nil { - log.Error().Err(err).Msg("Failed to recreate bootstrap token during first-run reset") - http.Error(w, "Failed to recreate bootstrap token", http.StatusInternalServerError) - return - } r.bootstrapTokenHash = internalauth.HashAPIToken(token) r.bootstrapTokenPath = path diff --git a/internal/api/security_first_run_reset_test.go b/internal/api/security_first_run_reset_test.go index d7db86e51..37f481a59 100644 --- a/internal/api/security_first_run_reset_test.go +++ b/internal/api/security_first_run_reset_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" ) @@ -132,3 +133,82 @@ func TestResetFirstRunSecurityClearsEnvBackedAuthFromStatus(t *testing.T) { t.Fatalf("detailLevel = %v, want %q", got, securityStatusDetailPublic) } } + +func TestResetFirstRunSecurityPreservesLiveCredentialsWhenTokenPersistenceFails(t *testing.T) { + t.Setenv("PULSE_DEV", "true") + t.Setenv("NODE_ENV", "") + t.Setenv("PULSE_AUTH_USER", "admin") + t.Setenv("PULSE_AUTH_PASS", "hashed-password") + t.Setenv("REQUIRE_AUTH", "true") + + rawToken := "reset-first-run-token-persist-failure.12345678" + record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil) + cfg := newTestConfigWithTokens(t, record) + cfg.AuthUser = "admin" + cfg.AuthPass = "hashed-password" + cfg.SortAPITokens() + authEnvContent := []byte("PULSE_AUTH_USER='admin'\nPULSE_AUTH_PASS='hashed-password'\nREQUIRE_AUTH='true'\n") + authEnvPath, err := writeAuthEnvFile(cfg.ConfigPath, cfg.DataPath, authEnvContent) + if err != nil { + t.Fatalf("write auth environment file: %v", err) + } + + persistence := config.NewConfigPersistence(cfg.DataPath) + if err := persistence.SaveAPITokens(cfg.APITokens); err != nil { + t.Fatalf("persist initial API tokens: %v", err) + } + persistence.SetFileSystem(failingWriteFileSystem{}) + + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + router.persistence = persistence + sessionToken := "reset-first-run-session-persist-failure" + GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "admin") + + req := httptest.NewRequest(http.MethodPost, "/api/security/dev/reset-first-run", nil) + req.Header.Set("X-API-Token", rawToken) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d (%s)", rec.Code, rec.Body.String()) + } + if cfg.AuthUser != "admin" || cfg.AuthPass != "hashed-password" { + t.Fatalf("failed reset changed live password auth: user=%q pass=%q", cfg.AuthUser, cfg.AuthPass) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != record.ID { + t.Fatalf("failed reset changed live API tokens: %+v", cfg.APITokens) + } + if cfg.APIToken != record.Hash || !cfg.IsValidAPIToken(rawToken) { + t.Fatal("failed reset changed the live primary API token projection") + } + if !ValidateSession(sessionToken) { + t.Fatal("failed reset invalidated an existing user session") + } + if _, err := os.Stat(filepath.Join(cfg.DataPath, bootstrapTokenFilename)); !os.IsNotExist(err) { + t.Fatalf("failed reset left a staged bootstrap token behind: %v", err) + } + gotAuthEnv, err := os.ReadFile(authEnvPath) + if err != nil { + t.Fatalf("failed reset did not restore auth environment file: %v", err) + } + if string(gotAuthEnv) != string(authEnvContent) { + t.Fatalf("restored auth environment file = %q, want %q", gotAuthEnv, authEnvContent) + } + for key, want := range map[string]string{ + "PULSE_AUTH_USER": "admin", + "PULSE_AUTH_PASS": "hashed-password", + "REQUIRE_AUTH": "true", + } { + if got := os.Getenv(key); got != want { + t.Fatalf("failed reset changed %s=%q, want %q", key, got, want) + } + } + + persisted, err := config.NewConfigPersistence(cfg.DataPath).LoadAPITokens() + if err != nil { + t.Fatalf("load persisted API tokens: %v", err) + } + if len(persisted) != 1 || persisted[0].ID != record.ID { + t.Fatalf("failed reset changed restart-time API tokens: %+v", persisted) + } +}