mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-19 07:50:43 +01:00
- Change default server listen addresses to empty string (listen on all interfaces including IPv6) - Add short hostname matching fallback in host lookup API to handle FQDN vs short name mismatches - Implement retry loop (30s) in both Windows and Linux/macOS installers for registration verification - Fix lint errors: remove unnecessary fmt.Sprintf and nil checks before len() This resolves the 'Installer could not yet confirm host registration with Pulse' warning by addressing timing issues, hostname matching, and network connectivity.
695 lines
20 KiB
Go
695 lines
20 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
cryptorand "crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"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
|
|
adminBypassState struct {
|
|
once sync.Once
|
|
enabled bool
|
|
declined bool
|
|
}
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ValidateAndExtendSession validates a session and extends its expiration (sliding window)
|
|
func ValidateAndExtendSession(token string) bool {
|
|
return GetSessionStore().ValidateAndExtendSession(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().
|
|
Int("provided_secret_length", 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 {
|
|
config.Mu.RLock()
|
|
defer config.Mu.RUnlock()
|
|
|
|
// Check proxy auth first if configured
|
|
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
|
|
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 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)
|
|
if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.HasAPITokens() {
|
|
// Check if an API token was provided
|
|
providedToken := r.Header.Get("X-API-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
|
|
}
|
|
|
|
// Require a valid token for all requests in API-only mode
|
|
if w != nil {
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="API token required; supply via Authorization header or X-API-Token header"`)
|
|
http.Error(w, "API token required via Authorization header or X-API-Token header", 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check session cookie (for WebSocket and UI)
|
|
if cookie, err := r.Cookie("pulse_session"); err == nil && cookie.Value != "" {
|
|
// Use ValidateAndExtendSession for sliding expiration
|
|
if ValidateAndExtendSession(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")
|
|
authScheme := "none"
|
|
if auth != "" {
|
|
if idx := strings.IndexByte(auth, ' '); idx != -1 {
|
|
authScheme = strings.ToLower(auth[:idx])
|
|
} else {
|
|
authScheme = strings.ToLower(auth)
|
|
}
|
|
}
|
|
log.Debug().Str("auth_scheme", authScheme).Str("url", r.URL.Path).Msg("Checking Authorization header")
|
|
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(`{"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 adminBypassEnabled() {
|
|
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)
|
|
if adminBypassEnabled() {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// RequireScope ensures that token-authenticated requests include the specified scope.
|
|
// Session-based (browser) requests bypass the scope check.
|
|
func RequireScope(scope string, handler http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if scope == "" {
|
|
handler(w, r)
|
|
return
|
|
}
|
|
|
|
record := getAPITokenRecordFromRequest(r)
|
|
if record == nil {
|
|
// Session-authenticated request
|
|
handler(w, r)
|
|
return
|
|
}
|
|
|
|
if record.HasScope(scope) {
|
|
handler(w, r)
|
|
return
|
|
}
|
|
|
|
log.Warn().
|
|
Str("token_id", record.ID).
|
|
Str("required_scope", scope).
|
|
Msg("API token missing required scope")
|
|
|
|
respondMissingScope(w, scope)
|
|
}
|
|
}
|
|
|
|
func respondMissingScope(w http.ResponseWriter, scope string) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"error": "missing_scope",
|
|
"requiredScope": scope,
|
|
})
|
|
}
|
|
|
|
// ensureScope enforces that the request either originates from a session or a token
|
|
// possessing the specified scope. Returns true when access should continue.
|
|
func ensureScope(w http.ResponseWriter, r *http.Request, scope string) bool {
|
|
if scope == "" {
|
|
return true
|
|
}
|
|
record := getAPITokenRecordFromRequest(r)
|
|
if record == nil || record.HasScope(scope) {
|
|
return true
|
|
}
|
|
respondMissingScope(w, scope)
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|
|
func adminBypassEnabled() bool {
|
|
adminBypassState.once.Do(func() {
|
|
if os.Getenv("ALLOW_ADMIN_BYPASS") != "1" {
|
|
return
|
|
}
|
|
|
|
if os.Getenv("PULSE_DEV") == "true" || strings.EqualFold(os.Getenv("NODE_ENV"), "development") {
|
|
log.Warn().Msg("Admin authentication bypass ENABLED (development mode)")
|
|
adminBypassState.enabled = true
|
|
return
|
|
}
|
|
|
|
log.Warn().Msg("Ignoring ALLOW_ADMIN_BYPASS outside development mode")
|
|
adminBypassState.declined = true
|
|
})
|
|
return adminBypassState.enabled
|
|
}
|