mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 23:41:48 +01:00
Improvements to pulse-sensor-proxy: - Fix cluster discovery to use pvecm status for IP addresses instead of node names - Add standalone node support for non-clustered Proxmox hosts - Enhanced SSH key push with detailed logging, success/failure tracking, and error reporting - Add --pulse-server flag to installer for custom Pulse URLs - Configure www-data group membership for Proxmox IPC access UI and API cleanup: - Remove unused "Ensure cluster keys" button from Settings - Remove /api/diagnostics/temperature-proxy/ensure-cluster-keys endpoint - Remove EnsureClusterKeys method from tempproxy client The setup script already handles SSH key distribution during initial configuration, making the manual refresh button redundant.
642 lines
19 KiB
Go
642 lines
19 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
cryptorand "crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
const (
|
|
contextKeyAPIToken contextKey = "apiTokenRecord"
|
|
)
|
|
|
|
// Global session store instance
|
|
var (
|
|
sessionStore *SessionStore
|
|
sessionOnce sync.Once
|
|
)
|
|
|
|
// InitSessionStore initializes the persistent session store
|
|
func InitSessionStore(dataPath string) {
|
|
sessionOnce.Do(func() {
|
|
sessionStore = NewSessionStore(dataPath)
|
|
})
|
|
}
|
|
|
|
// GetSessionStore returns the global session store instance
|
|
func GetSessionStore() *SessionStore {
|
|
if sessionStore == nil {
|
|
// Initialize with default path if not already initialized
|
|
InitSessionStore("/etc/pulse")
|
|
}
|
|
return sessionStore
|
|
}
|
|
|
|
// detectProxy checks if the request is coming through a reverse proxy
|
|
func detectProxy(r *http.Request) bool {
|
|
// Check multiple headers that proxies commonly set
|
|
return r.Header.Get("X-Forwarded-For") != "" ||
|
|
r.Header.Get("X-Real-IP") != "" ||
|
|
r.Header.Get("X-Forwarded-Proto") != "" ||
|
|
r.Header.Get("X-Forwarded-Host") != "" ||
|
|
r.Header.Get("Forwarded") != "" || // RFC 7239
|
|
r.Header.Get("CF-Ray") != "" || // Cloudflare
|
|
r.Header.Get("CF-Connecting-IP") != "" || // Cloudflare
|
|
r.Header.Get("X-Forwarded-Server") != "" || // Some proxies
|
|
r.Header.Get("X-Forwarded-Port") != "" // Some proxies
|
|
}
|
|
|
|
// isConnectionSecure checks if the connection is over HTTPS
|
|
func isConnectionSecure(r *http.Request) bool {
|
|
return r.TLS != nil ||
|
|
r.Header.Get("X-Forwarded-Proto") == "https" ||
|
|
strings.Contains(r.Header.Get("Forwarded"), "proto=https")
|
|
}
|
|
|
|
// getCookieSettings returns the appropriate cookie settings based on proxy detection
|
|
func getCookieSettings(r *http.Request) (secure bool, sameSite http.SameSite) {
|
|
isProxied := detectProxy(r)
|
|
isSecure := isConnectionSecure(r)
|
|
|
|
// Debug logging for Cloudflare tunnel issues
|
|
if isProxied {
|
|
log.Debug().
|
|
Bool("proxied", isProxied).
|
|
Bool("secure", isSecure).
|
|
Str("cf_ray", r.Header.Get("CF-Ray")).
|
|
Str("cf_connecting_ip", r.Header.Get("CF-Connecting-IP")).
|
|
Str("x_forwarded_for", r.Header.Get("X-Forwarded-For")).
|
|
Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")).
|
|
Msg("Proxy/tunnel detected - adjusting cookie settings")
|
|
}
|
|
|
|
// Default to Lax for better compatibility
|
|
sameSitePolicy := http.SameSiteLaxMode
|
|
|
|
if isProxied {
|
|
// For proxied connections, we need to be more permissive
|
|
// But only use None if connection is secure (required by browsers)
|
|
if isSecure {
|
|
sameSitePolicy = http.SameSiteNoneMode
|
|
} else {
|
|
// For HTTP proxies, stay with Lax for compatibility
|
|
sameSitePolicy = http.SameSiteLaxMode
|
|
}
|
|
}
|
|
|
|
return isSecure, sameSitePolicy
|
|
}
|
|
|
|
// generateSessionToken creates a cryptographically secure session token
|
|
func generateSessionToken() string {
|
|
b := make([]byte, 32)
|
|
if _, err := cryptorand.Read(b); err != nil {
|
|
log.Error().Err(err).Msg("Failed to generate secure session token")
|
|
// Fallback - should never happen
|
|
return ""
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// ValidateSession checks if a session token is valid
|
|
func ValidateSession(token string) bool {
|
|
return GetSessionStore().ValidateSession(token)
|
|
}
|
|
|
|
// CheckProxyAuth validates proxy authentication headers
|
|
func CheckProxyAuth(cfg *config.Config, r *http.Request) (bool, string, bool) {
|
|
// Check if proxy auth is configured
|
|
if cfg.ProxyAuthSecret == "" {
|
|
return false, "", false
|
|
}
|
|
|
|
// Validate proxy secret header
|
|
proxySecret := r.Header.Get("X-Proxy-Secret")
|
|
if proxySecret != cfg.ProxyAuthSecret {
|
|
log.Debug().
|
|
Str("provided_secret", proxySecret[:min(8, len(proxySecret))]+"...").
|
|
Msg("Invalid proxy secret")
|
|
return false, "", false
|
|
}
|
|
|
|
// Get username from header if configured
|
|
username := ""
|
|
if cfg.ProxyAuthUserHeader != "" {
|
|
username = r.Header.Get(cfg.ProxyAuthUserHeader)
|
|
if username == "" {
|
|
log.Debug().Str("header", cfg.ProxyAuthUserHeader).Msg("Proxy auth user header not found")
|
|
return false, "", false
|
|
}
|
|
}
|
|
|
|
// Check admin role if configured
|
|
isAdmin := true // Default to admin if no role checking configured
|
|
if cfg.ProxyAuthRoleHeader != "" && cfg.ProxyAuthAdminRole != "" {
|
|
roles := r.Header.Get(cfg.ProxyAuthRoleHeader)
|
|
if roles != "" {
|
|
// Split roles by separator
|
|
separator := cfg.ProxyAuthRoleSeparator
|
|
if separator == "" {
|
|
separator = "|"
|
|
}
|
|
roleList := strings.Split(roles, separator)
|
|
isAdmin = false
|
|
for _, role := range roleList {
|
|
if strings.TrimSpace(role) == cfg.ProxyAuthAdminRole {
|
|
isAdmin = true
|
|
break
|
|
}
|
|
}
|
|
log.Debug().
|
|
Str("roles", roles).
|
|
Bool("is_admin", isAdmin).
|
|
Msg("Proxy auth roles checked")
|
|
}
|
|
}
|
|
|
|
log.Debug().
|
|
Str("user", username).
|
|
Bool("is_admin", isAdmin).
|
|
Msg("Proxy authentication successful")
|
|
|
|
return true, username, isAdmin
|
|
}
|
|
|
|
// min returns the minimum of two integers
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// CheckAuth checks both basic auth and API token
|
|
func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool {
|
|
// Check proxy auth first if configured (even if DISABLE_AUTH is true)
|
|
if cfg.ProxyAuthSecret != "" {
|
|
if valid, username, _ := CheckProxyAuth(cfg, r); valid {
|
|
// Set username in response header for frontend
|
|
if username != "" {
|
|
w.Header().Set("X-Authenticated-User", username)
|
|
}
|
|
w.Header().Set("X-Auth-Method", "proxy")
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check for OIDC session cookie (even if DISABLE_AUTH is true)
|
|
if cfg.OIDC != nil && cfg.OIDC.Enabled {
|
|
if cookie, err := r.Cookie("pulse_session"); err == nil && cookie.Value != "" {
|
|
if ValidateSession(cookie.Value) {
|
|
// Check if this is an OIDC session
|
|
if username := GetSessionUsername(cookie.Value); username != "" {
|
|
w.Header().Set("X-Authenticated-User", username)
|
|
w.Header().Set("X-Auth-Method", "oidc")
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If auth is explicitly disabled, allow all access
|
|
// (but only after checking proxy and OIDC auth above)
|
|
if cfg.DisableAuth {
|
|
w.Header().Set("X-Auth-Disabled", "true")
|
|
return true
|
|
}
|
|
|
|
// If no auth is configured at all, allow access unless OIDC is enabled
|
|
if cfg.AuthUser == "" && cfg.AuthPass == "" && !cfg.HasAPITokens() && cfg.ProxyAuthSecret == "" {
|
|
if cfg.OIDC != nil && cfg.OIDC.Enabled {
|
|
log.Debug().Msg("OIDC enabled without local credentials, authentication required")
|
|
} else {
|
|
log.Debug().Msg("No auth configured, allowing access")
|
|
return true
|
|
}
|
|
}
|
|
|
|
// API-only mode: when only API token is configured (no password auth)
|
|
// Allow read-only endpoints for the UI to work
|
|
if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.HasAPITokens() {
|
|
// Check if an API token was provided
|
|
providedToken := r.Header.Get("X-API-Token")
|
|
if providedToken == "" {
|
|
providedToken = r.URL.Query().Get("token")
|
|
}
|
|
|
|
// If a token was provided, validate it
|
|
if providedToken != "" {
|
|
if record, ok := cfg.ValidateAPIToken(providedToken); ok {
|
|
attachAPITokenRecord(r, record)
|
|
return true
|
|
}
|
|
// Invalid token provided
|
|
if w != nil {
|
|
http.Error(w, "Invalid API token", http.StatusUnauthorized)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// No token provided - allow read-only endpoints for UI
|
|
if r.Method == "GET" || r.URL.Path == "/ws" {
|
|
// Allow these endpoints without auth for UI to function
|
|
allowedPaths := []string{
|
|
"/api/state",
|
|
"/api/config/nodes",
|
|
"/api/config/system",
|
|
"/api/settings",
|
|
"/api/discover",
|
|
"/api/security/status",
|
|
"/api/version",
|
|
"/api/health",
|
|
"/api/updates/check",
|
|
"/api/system/diagnostics",
|
|
"/api/guests/metadata",
|
|
"/ws", // WebSocket for real-time updates
|
|
}
|
|
for _, path := range allowedPaths {
|
|
if r.URL.Path == path || strings.HasPrefix(r.URL.Path, path+"/") {
|
|
log.Debug().Str("path", r.URL.Path).Msg("Allowing read-only access in API-only mode")
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Require token for everything else
|
|
if w != nil {
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="API Token Required"`)
|
|
http.Error(w, "API token required", http.StatusUnauthorized)
|
|
}
|
|
return false
|
|
}
|
|
|
|
log.Debug().
|
|
Str("configured_user", cfg.AuthUser).
|
|
Bool("has_pass", cfg.AuthPass != "").
|
|
Bool("has_token", cfg.HasAPITokens()).
|
|
Str("url", r.URL.Path).
|
|
Msg("Checking authentication")
|
|
|
|
validateToken := func(token string) bool {
|
|
if token == "" {
|
|
return false
|
|
}
|
|
if record, ok := cfg.ValidateAPIToken(token); ok {
|
|
attachAPITokenRecord(r, record)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Check API tokens (header, bearer, query) before other auth methods
|
|
if cfg.HasAPITokens() {
|
|
if validateToken(r.Header.Get("X-API-Token")) {
|
|
return true
|
|
}
|
|
if authHeader := r.Header.Get("Authorization"); authHeader != "" {
|
|
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
|
if validateToken(strings.TrimSpace(authHeader[7:])) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
if validateToken(r.URL.Query().Get("token")) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check session cookie (for WebSocket and UI)
|
|
if cookie, err := r.Cookie("pulse_session"); err == nil && cookie.Value != "" {
|
|
if ValidateSession(cookie.Value) {
|
|
return true
|
|
} else {
|
|
// Debug logging for failed session validation
|
|
log.Debug().
|
|
Str("session_token", cookie.Value[:8]+"...").
|
|
Str("path", r.URL.Path).
|
|
Msg("Session validation failed - token not found or expired")
|
|
}
|
|
} else if err != nil {
|
|
// Debug logging when no session cookie found
|
|
log.Debug().
|
|
Err(err).
|
|
Str("path", r.URL.Path).
|
|
Bool("has_cf_headers", r.Header.Get("CF-Ray") != "").
|
|
Msg("No session cookie found")
|
|
}
|
|
|
|
// Check basic auth
|
|
if cfg.AuthUser != "" && cfg.AuthPass != "" {
|
|
auth := r.Header.Get("Authorization")
|
|
log.Debug().Str("auth_header", auth).Str("url", r.URL.Path).Msg("Checking auth")
|
|
if auth != "" {
|
|
const prefix = "Basic "
|
|
if strings.HasPrefix(auth, prefix) {
|
|
decoded, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
|
|
if err == nil {
|
|
parts := strings.SplitN(string(decoded), ":", 2)
|
|
if len(parts) == 2 {
|
|
clientIP := GetClientIP(r)
|
|
|
|
// Only apply rate limiting for actual login attempts, not regular auth checks
|
|
// Login attempts come to /api/login endpoint
|
|
if r.URL.Path == "/api/login" {
|
|
// Check rate limiting for auth attempts
|
|
if !authLimiter.Allow(clientIP) {
|
|
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for auth")
|
|
LogAuditEvent("login", parts[0], clientIP, r.URL.Path, false, "Rate limited")
|
|
if w != nil {
|
|
http.Error(w, "Too many authentication attempts", http.StatusTooManyRequests)
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check if account is locked out
|
|
_, userLockedUntil, userLocked := GetLockoutInfo(parts[0])
|
|
_, ipLockedUntil, ipLocked := GetLockoutInfo(clientIP)
|
|
|
|
if userLocked || ipLocked {
|
|
lockedUntil := userLockedUntil
|
|
if ipLocked && ipLockedUntil.After(lockedUntil) {
|
|
lockedUntil = ipLockedUntil
|
|
}
|
|
|
|
remainingMinutes := int(time.Until(lockedUntil).Minutes())
|
|
if remainingMinutes < 1 {
|
|
remainingMinutes = 1
|
|
}
|
|
|
|
log.Warn().Str("user", parts[0]).Str("ip", clientIP).Msg("Account locked out")
|
|
LogAuditEvent("login", parts[0], clientIP, r.URL.Path, false, "Account locked")
|
|
if w != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte(fmt.Sprintf(`{"error":"Account temporarily locked","message":"Too many failed attempts. Please try again in %d minutes.","lockedUntil":"%s"}`,
|
|
remainingMinutes, lockedUntil.Format(time.RFC3339))))
|
|
}
|
|
return false
|
|
}
|
|
// Check username
|
|
userMatch := parts[0] == cfg.AuthUser
|
|
|
|
// Check password - support both hashed and plain text for migration
|
|
// Config always has hashed password now (auto-hashed on load)
|
|
passMatch := internalauth.CheckPasswordHash(parts[1], cfg.AuthPass)
|
|
|
|
log.Debug().
|
|
Str("provided_user", parts[0]).
|
|
Str("expected_user", cfg.AuthUser).
|
|
Bool("user_match", userMatch).
|
|
Bool("pass_match", passMatch).
|
|
Msg("Auth check")
|
|
|
|
if userMatch && passMatch {
|
|
// Clear failed login attempts
|
|
ClearFailedLogins(parts[0])
|
|
ClearFailedLogins(GetClientIP(r))
|
|
|
|
// Valid credentials - create session
|
|
if w != nil {
|
|
token := generateSessionToken()
|
|
if token == "" {
|
|
return false
|
|
}
|
|
|
|
// Store session persistently
|
|
userAgent := r.Header.Get("User-Agent")
|
|
clientIP := GetClientIP(r)
|
|
GetSessionStore().CreateSession(token, 24*time.Hour, userAgent, clientIP)
|
|
|
|
// Track session for user
|
|
TrackUserSession(parts[0], token)
|
|
|
|
// Generate CSRF token
|
|
csrfToken := generateCSRFToken(token)
|
|
|
|
// Get appropriate cookie settings based on proxy detection
|
|
isSecure, sameSitePolicy := getCookieSettings(r)
|
|
|
|
// Debug logging for Cloudflare tunnel issues
|
|
sameSiteName := "Default"
|
|
switch sameSitePolicy {
|
|
case http.SameSiteNoneMode:
|
|
sameSiteName = "None"
|
|
case http.SameSiteLaxMode:
|
|
sameSiteName = "Lax"
|
|
case http.SameSiteStrictMode:
|
|
sameSiteName = "Strict"
|
|
}
|
|
|
|
log.Debug().
|
|
Bool("secure", isSecure).
|
|
Str("same_site", sameSiteName).
|
|
Str("token", token[:8]+"...").
|
|
Str("remote_addr", r.RemoteAddr).
|
|
Msg("Setting session cookie after successful login")
|
|
|
|
// Set session cookie
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "pulse_session",
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: isSecure,
|
|
SameSite: sameSitePolicy,
|
|
MaxAge: 86400, // 24 hours
|
|
})
|
|
|
|
// Set CSRF cookie (not HttpOnly so JS can read it)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "pulse_csrf",
|
|
Value: csrfToken,
|
|
Path: "/",
|
|
Secure: isSecure,
|
|
SameSite: sameSitePolicy,
|
|
MaxAge: 86400, // 24 hours
|
|
})
|
|
|
|
// Audit log successful login
|
|
LogAuditEvent("login", parts[0], GetClientIP(r), r.URL.Path, true, "Basic auth login")
|
|
}
|
|
return true
|
|
} else {
|
|
// Failed login
|
|
RecordFailedLogin(parts[0])
|
|
RecordFailedLogin(clientIP)
|
|
LogAuditEvent("login", parts[0], clientIP, r.URL.Path, false, "Invalid credentials")
|
|
|
|
// Get updated attempt counts
|
|
newUserAttempts, _, _ := GetLockoutInfo(parts[0])
|
|
newIPAttempts, _, _ := GetLockoutInfo(clientIP)
|
|
|
|
// Use the higher count for warning
|
|
attempts := newUserAttempts
|
|
if newIPAttempts > attempts {
|
|
attempts = newIPAttempts
|
|
}
|
|
|
|
if r.URL.Path == "/api/login" && w != nil {
|
|
// For login endpoint, provide detailed error response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
remaining := maxFailedAttempts - attempts
|
|
if remaining > 0 {
|
|
w.Write([]byte(fmt.Sprintf(`{"error":"Invalid credentials","attempts":%d,"remaining":%d,"maxAttempts":%d}`,
|
|
attempts, remaining, maxFailedAttempts)))
|
|
} else {
|
|
w.Write([]byte(fmt.Sprintf(`{"error":"Invalid credentials","locked":true,"message":"Account locked for 15 minutes"}`)))
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// RequireAuth middleware checks for authentication
|
|
func RequireAuth(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Dev mode bypass for all auth (disabled by default)
|
|
if os.Getenv("ALLOW_ADMIN_BYPASS") == "1" {
|
|
log.Debug().
|
|
Str("path", r.URL.Path).
|
|
Msg("Auth bypass enabled for dev mode")
|
|
handler(w, r)
|
|
return
|
|
}
|
|
|
|
if CheckAuth(cfg, w, r) {
|
|
handler(w, r)
|
|
return
|
|
}
|
|
|
|
// Log the failed attempt
|
|
log.Warn().
|
|
Str("ip", r.RemoteAddr).
|
|
Str("path", r.URL.Path).
|
|
Str("method", r.Method).
|
|
Msg("Unauthorized access attempt")
|
|
|
|
// Never send WWW-Authenticate header - we want to use our custom login page
|
|
// The frontend will detect 401 responses and show the login component
|
|
// Return JSON error for API requests, plain text for others
|
|
if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte(`{"error":"Authentication required"}`))
|
|
} else {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RequireAdmin middleware checks for authentication and admin privileges
|
|
// For proxy auth users, it ensures they have the admin role
|
|
// For other auth methods, all authenticated users are considered admins
|
|
func RequireAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Dev mode bypass for admin endpoints (disabled by default)
|
|
bypassValue := os.Getenv("ALLOW_ADMIN_BYPASS")
|
|
log.Info().
|
|
Str("bypass_value", bypassValue).
|
|
Str("path", r.URL.Path).
|
|
Msg("=== CHECKING ADMIN BYPASS ===")
|
|
if bypassValue == "1" {
|
|
log.Debug().
|
|
Str("path", r.URL.Path).
|
|
Msg("Admin bypass enabled for dev mode")
|
|
handler(w, r)
|
|
return
|
|
}
|
|
|
|
// First check if user is authenticated
|
|
if !CheckAuth(cfg, w, r) {
|
|
// Log the failed attempt
|
|
log.Warn().
|
|
Str("ip", r.RemoteAddr).
|
|
Str("path", r.URL.Path).
|
|
Str("method", r.Method).
|
|
Msg("Unauthorized access attempt")
|
|
|
|
// Return authentication error
|
|
if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte(`{"error":"Authentication required"}`))
|
|
} else {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Check if using proxy auth and if so, verify admin status
|
|
if cfg.ProxyAuthSecret != "" {
|
|
if valid, username, isAdmin := CheckProxyAuth(cfg, r); valid {
|
|
if !isAdmin {
|
|
// User is authenticated but not an admin
|
|
log.Warn().
|
|
Str("ip", r.RemoteAddr).
|
|
Str("path", r.URL.Path).
|
|
Str("method", r.Method).
|
|
Str("username", username).
|
|
Msg("Non-admin user attempted to access admin endpoint")
|
|
|
|
// Return forbidden error
|
|
if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte(`{"error":"Admin privileges required"}`))
|
|
} else {
|
|
http.Error(w, "Admin privileges required", http.StatusForbidden)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// User is authenticated and has admin privileges (or not using proxy auth)
|
|
handler(w, r)
|
|
}
|
|
}
|
|
|
|
func attachAPITokenRecord(r *http.Request, record *config.APITokenRecord) {
|
|
if record == nil {
|
|
return
|
|
}
|
|
clone := record.Clone()
|
|
ctx := context.WithValue(r.Context(), contextKeyAPIToken, clone)
|
|
*r = *r.WithContext(ctx)
|
|
}
|
|
|
|
func getAPITokenRecordFromRequest(r *http.Request) *config.APITokenRecord {
|
|
value := r.Context().Value(contextKeyAPIToken)
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
record, ok := value.(config.APITokenRecord)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
clone := record.Clone()
|
|
return &clone
|
|
}
|