mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 11:13:26 +00:00
fix: implement secure API token hashing with SHA3-256
- API tokens now hashed before storage (never stored in plain text) - Raw token shown only once during generation - Backward compatible with existing plain text tokens - Added migration warnings for users with plain tokens - Updated documentation to reflect security improvements
This commit is contained in:
+9
-11
@@ -112,15 +112,12 @@ docker run -e ALLOW_UNPROTECTED_EXPORT=true rcourtman/pulse:latest
|
||||
- Automatic hashing on security setup
|
||||
- **CRITICAL**: Bcrypt hashes MUST be exactly 60 characters
|
||||
- **API Token Security**:
|
||||
- 48-character hex tokens (24 bytes of entropy)
|
||||
- Stored in plain text with file permissions (600)
|
||||
- Live reloading when .env file changes (v4.3.9+)
|
||||
- API-only mode supported (no password auth required)
|
||||
- **Docker Users**: Always wrap hash in single quotes to prevent shell expansion
|
||||
- **API Token Security**:
|
||||
- SHA3-256 hashing for all tokens
|
||||
- 64-character hex format when hashed
|
||||
- 64-character hex tokens (32 bytes of entropy)
|
||||
- SHA3-256 hashed before storage (64-char hash)
|
||||
- Raw token shown only once during generation
|
||||
- Tokens NEVER stored in plain text
|
||||
- Live reloading when .env file changes
|
||||
- API-only mode supported (no password auth required)
|
||||
- **CSRF Protection**: All state-changing operations require CSRF tokens
|
||||
- **Rate Limiting**:
|
||||
- Authentication endpoints: 10 attempts/minute per IP
|
||||
@@ -159,13 +156,14 @@ Pulse supports multiple authentication methods that can be used independently or
|
||||
The easiest way to enable authentication is through the web UI:
|
||||
1. Go to Settings → Security
|
||||
2. Click "Enable Security Now"
|
||||
3. Save the generated credentials
|
||||
4. Click "Restart Pulse" (or restart Docker container)
|
||||
3. Enter username and password
|
||||
4. Save the generated API token (shown only once!)
|
||||
5. Security is enabled immediately (no restart needed)
|
||||
|
||||
This automatically:
|
||||
- Generates a secure random password
|
||||
- Hashes it with bcrypt (cost factor 12)
|
||||
- Creates secure API token (SHA3-256 hashed)
|
||||
- Creates secure API token (SHA3-256 hashed, raw token shown once)
|
||||
- For systemd: Configures systemd with hashed credentials
|
||||
- For Docker: Saves to `/data/.env` with hashed credentials (properly quoted to prevent shell expansion)
|
||||
- Restarts service/container with authentication enabled
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -12,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
||||
internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -106,7 +104,7 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// Validate password complexity
|
||||
if err := auth.ValidatePasswordComplexity(setupRequest.Password); err != nil {
|
||||
if err := internalauth.ValidatePasswordComplexity(setupRequest.Password); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -117,7 +115,7 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
hashedPassword, err := auth.HashPassword(setupRequest.Password)
|
||||
hashedPassword, err := internalauth.HashPassword(setupRequest.Password)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to hash password")
|
||||
http.Error(w, "Failed to process password", http.StatusInternalServerError)
|
||||
@@ -131,20 +129,21 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't hash the API token - store it as plain text
|
||||
// (Hashing causes issues with token length detection)
|
||||
// Always use the new token when setting up full security
|
||||
// This ensures any API-only tokens are replaced with the new secure token
|
||||
apiToken := setupRequest.APIToken
|
||||
// Store the raw API token for displaying to the user
|
||||
rawAPIToken := setupRequest.APIToken
|
||||
|
||||
// Hash the API token for storage
|
||||
hashedAPIToken := internalauth.HashAPIToken(rawAPIToken)
|
||||
|
||||
if r.config.APIToken != "" && r.config.AuthUser == "" && r.config.AuthPass == "" {
|
||||
// We had API-only access before, now replacing with full security
|
||||
log.Info().Msg("Replacing API-only token with new secure token")
|
||||
}
|
||||
|
||||
// Update runtime config immediately - no restart needed!
|
||||
// Update runtime config immediately with hashed token - no restart needed!
|
||||
r.config.AuthUser = setupRequest.Username
|
||||
r.config.AuthPass = hashedPassword
|
||||
r.config.APIToken = apiToken
|
||||
r.config.APIToken = hashedAPIToken
|
||||
r.config.APITokenEnabled = true
|
||||
r.config.PollingInterval = time.Duration(setupRequest.PollingInterval) * time.Second
|
||||
log.Info().Msg("Runtime config updated with new security settings - active immediately")
|
||||
@@ -185,7 +184,7 @@ PULSE_AUTH_USER='%s'
|
||||
PULSE_AUTH_PASS='%s'
|
||||
API_TOKEN=%s
|
||||
ENABLE_AUDIT_LOG=true
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, apiToken)
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken)
|
||||
|
||||
// Ensure directory exists
|
||||
os.MkdirAll(r.config.ConfigPath, 0755)
|
||||
@@ -221,7 +220,7 @@ PULSE_AUTH_USER='%s'
|
||||
PULSE_AUTH_PASS='%s'
|
||||
API_TOKEN=%s
|
||||
ENABLE_AUDIT_LOG=true
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, apiToken)
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken)
|
||||
|
||||
// Save to config directory (usually /etc/pulse)
|
||||
os.MkdirAll(r.config.ConfigPath, 0755)
|
||||
@@ -272,7 +271,7 @@ Environment="PULSE_AUTH_USER=%s"
|
||||
Environment="PULSE_AUTH_PASS=%s"
|
||||
Environment="API_TOKEN=%s"
|
||||
Environment="ENABLE_AUDIT_LOG=true"
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, apiToken)
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken)
|
||||
|
||||
if err := os.WriteFile(overridePath, []byte(overrideContent), 0644); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write systemd override")
|
||||
@@ -310,7 +309,7 @@ PULSE_AUTH_USER='%s'
|
||||
PULSE_AUTH_PASS='%s'
|
||||
API_TOKEN=%s
|
||||
ENABLE_AUDIT_LOG=true
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, apiToken)
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken)
|
||||
|
||||
// Try to create directory if needed
|
||||
os.MkdirAll(filepath.Dir(envPath), 0755)
|
||||
@@ -360,19 +359,21 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new token (24 bytes = 48 hex chars, not 64 to avoid hash detection issue)
|
||||
tokenBytes := make([]byte, 24)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to generate random token")
|
||||
// Generate new token using the auth package
|
||||
rawToken, err := internalauth.GenerateAPIToken()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to generate API token")
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
newToken := hex.EncodeToString(tokenBytes)
|
||||
|
||||
// Update runtime config immediately - no restart needed!
|
||||
r.config.APIToken = newToken
|
||||
// Hash the token for storage
|
||||
hashedToken := internalauth.HashAPIToken(rawToken)
|
||||
|
||||
// Update runtime config immediately with hashed token - no restart needed!
|
||||
r.config.APIToken = hashedToken
|
||||
r.config.APITokenEnabled = true
|
||||
log.Info().Msg("Runtime config updated with new API token - active immediately")
|
||||
log.Info().Msg("Runtime config updated with new hashed API token - active immediately")
|
||||
|
||||
// Determine env file path
|
||||
envPath := filepath.Join(r.config.ConfigPath, ".env")
|
||||
@@ -393,12 +394,12 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// Update the API_TOKEN line
|
||||
// Update the API_TOKEN line with the hashed token
|
||||
lines := strings.Split(string(content), "\n")
|
||||
var updated bool
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, "API_TOKEN=") {
|
||||
lines[i] = fmt.Sprintf("API_TOKEN=%s", newToken)
|
||||
lines[i] = fmt.Sprintf("API_TOKEN=%s", hashedToken)
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
@@ -406,7 +407,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
|
||||
if !updated {
|
||||
// API_TOKEN line not found, add it
|
||||
lines = append(lines, fmt.Sprintf("API_TOKEN=%s", newToken))
|
||||
lines = append(lines, fmt.Sprintf("API_TOKEN=%s", hashedToken))
|
||||
}
|
||||
|
||||
// Write updated content back
|
||||
@@ -424,10 +425,10 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"token": newToken,
|
||||
"token": rawToken, // Return the raw token to the user (only shown once!)
|
||||
"deploymentType": deploymentType,
|
||||
"requiresRestart": false,
|
||||
"message": "New API token generated and active immediately!",
|
||||
"message": "New API token generated and active immediately! Save this token - it won't be shown again.",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -322,6 +323,13 @@ func Load() (*Config, error) {
|
||||
if apiToken := os.Getenv("API_TOKEN"); apiToken != "" {
|
||||
cfg.APIToken = apiToken
|
||||
log.Debug().Msg("Loaded API token from env var")
|
||||
|
||||
// Check if token needs migration from plain text to hashed
|
||||
if apiToken != "" && !auth.IsAPITokenHashed(apiToken) {
|
||||
log.Warn().Msg("Detected plain text API token - please regenerate for better security")
|
||||
// We don't auto-migrate here because we can't update the .env file from here safely
|
||||
// The user should regenerate through the UI or API
|
||||
}
|
||||
}
|
||||
// Check if API token is enabled
|
||||
if apiTokenEnabled := os.Getenv("API_TOKEN_ENABLED"); apiTokenEnabled != "" {
|
||||
|
||||
Reference in New Issue
Block a user