mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-22 07:23:27 +00:00
RequirePermission was reading the authenticated username from
w.Header().Get("X-Authenticated-User"). The header is set by checkAuth
upstream, but response headers are mutable across the handler chain:
any middleware sitting between checkAuth and RequirePermission that
wrote that header would substitute an arbitrary identity, and the
RBAC authorizer would make its access decision against the
substituted value. Identity belongs on the request context, not in a
mutable response surface.
Read internalauth.GetUser(r.Context()) first — checkAuth already
calls attachUserContext on every auth path that produces a real user,
so the context is the authoritative source. Keep a defensive,
warn-logged fallback to the response header for the one upstream path
that sets the header but skips attachUserContext (the anonymous-user
flow), so the existing test contract is preserved while the warning
flags the source path to plug.
Regression test TestRequirePermissionUsesContextUsername constructs
the attack: a request whose context says "real-authenticated-user"
and whose response header says "evil-spoofed-user". The RBAC
authorizer must observe the context value.
1459 lines
46 KiB
Go
1459 lines
46 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
cryptorand "crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// Global session store instance
|
|
var (
|
|
sessionStore *SessionStore
|
|
sessionStoreDataPath string
|
|
sessionStoreMu sync.Mutex
|
|
adminBypassState struct {
|
|
once sync.Once
|
|
enabled bool
|
|
declined bool
|
|
}
|
|
)
|
|
|
|
const privilegedBrowserSessionMaxAge = 5 * time.Minute
|
|
|
|
type authRequirementFailure struct {
|
|
Status int
|
|
Code string
|
|
Message string
|
|
}
|
|
|
|
type ssoOIDCProviderAuthSnapshot struct {
|
|
ProviderID string
|
|
IssuerURL string
|
|
ClientID string
|
|
ClientSecret string
|
|
RedirectURL string
|
|
Scopes []string
|
|
UsernameClaim string
|
|
EmailClaim string
|
|
CABundle string
|
|
}
|
|
|
|
type ssoAuthSnapshot struct {
|
|
HasEnabledProviders bool
|
|
OIDCProviders []ssoOIDCProviderAuthSnapshot
|
|
}
|
|
|
|
func emptySSOAuthSnapshot() ssoAuthSnapshot {
|
|
snapshot := ssoAuthSnapshot{}
|
|
snapshot.normalizeCollections()
|
|
return snapshot
|
|
}
|
|
|
|
func (s *ssoAuthSnapshot) normalizeCollections() {
|
|
if s.OIDCProviders == nil {
|
|
s.OIDCProviders = []ssoOIDCProviderAuthSnapshot{}
|
|
}
|
|
}
|
|
|
|
var authSSOState = struct {
|
|
mu sync.RWMutex
|
|
byConfigID map[string]ssoAuthSnapshot
|
|
}{
|
|
byConfigID: make(map[string]ssoAuthSnapshot),
|
|
}
|
|
|
|
func authConfigID(cfg *config.Config) string {
|
|
if cfg == nil {
|
|
return ""
|
|
}
|
|
if id := strings.TrimSpace(cfg.DataPath); id != "" {
|
|
return id
|
|
}
|
|
return strings.TrimSpace(cfg.ConfigPath)
|
|
}
|
|
|
|
func buildSSOAuthSnapshot(ssoCfg *config.SSOConfig) ssoAuthSnapshot {
|
|
if ssoCfg == nil {
|
|
return emptySSOAuthSnapshot()
|
|
}
|
|
|
|
enabledProviders := ssoCfg.GetEnabledProviders()
|
|
snapshot := ssoAuthSnapshot{HasEnabledProviders: len(enabledProviders) > 0}
|
|
for _, provider := range enabledProviders {
|
|
if provider.Type != config.SSOProviderTypeOIDC || provider.OIDC == nil {
|
|
continue
|
|
}
|
|
scopes := append([]string{}, provider.OIDC.Scopes...)
|
|
if len(scopes) == 0 {
|
|
scopes = []string{"openid", "profile", "email"}
|
|
}
|
|
snapshot.OIDCProviders = append(snapshot.OIDCProviders, ssoOIDCProviderAuthSnapshot{
|
|
ProviderID: provider.ID,
|
|
IssuerURL: provider.OIDC.IssuerURL,
|
|
ClientID: provider.OIDC.ClientID,
|
|
ClientSecret: provider.OIDC.ClientSecret,
|
|
RedirectURL: provider.OIDC.RedirectURL,
|
|
Scopes: scopes,
|
|
UsernameClaim: provider.OIDC.UsernameClaim,
|
|
EmailClaim: provider.OIDC.EmailClaim,
|
|
CABundle: provider.OIDC.CABundle,
|
|
})
|
|
}
|
|
|
|
snapshot.normalizeCollections()
|
|
return snapshot
|
|
}
|
|
|
|
func setSSOAuthSnapshot(cfg *config.Config, ssoCfg *config.SSOConfig) {
|
|
configID := authConfigID(cfg)
|
|
if configID == "" {
|
|
return
|
|
}
|
|
|
|
authSSOState.mu.Lock()
|
|
authSSOState.byConfigID[configID] = buildSSOAuthSnapshot(ssoCfg)
|
|
authSSOState.mu.Unlock()
|
|
}
|
|
|
|
func getSSOAuthSnapshot(cfg *config.Config) ssoAuthSnapshot {
|
|
configID := authConfigID(cfg)
|
|
if configID == "" {
|
|
return emptySSOAuthSnapshot()
|
|
}
|
|
|
|
authSSOState.mu.RLock()
|
|
snapshot := authSSOState.byConfigID[configID]
|
|
authSSOState.mu.RUnlock()
|
|
snapshot.normalizeCollections()
|
|
return snapshot
|
|
}
|
|
|
|
func hasEnabledSSOProvidersForAuth(cfg *config.Config) bool {
|
|
return getSSOAuthSnapshot(cfg).HasEnabledProviders
|
|
}
|
|
|
|
func resolveOIDCRefreshConfig(cfg *config.Config, session *SessionData) (*config.OIDCConfig, string) {
|
|
if session == nil {
|
|
return nil, ""
|
|
}
|
|
|
|
issuer := strings.TrimSpace(session.OIDCIssuer)
|
|
if issuer == "" {
|
|
return nil, ""
|
|
}
|
|
|
|
sessionClientID := strings.TrimSpace(session.OIDCClientID)
|
|
snapshot := getSSOAuthSnapshot(cfg)
|
|
if !snapshot.HasEnabledProviders {
|
|
return nil, ""
|
|
}
|
|
|
|
for _, provider := range snapshot.OIDCProviders {
|
|
if strings.TrimSpace(provider.IssuerURL) != issuer {
|
|
continue
|
|
}
|
|
if sessionClientID != "" && strings.TrimSpace(provider.ClientID) != sessionClientID {
|
|
continue
|
|
}
|
|
return &config.OIDCConfig{
|
|
Enabled: true,
|
|
IssuerURL: provider.IssuerURL,
|
|
ClientID: provider.ClientID,
|
|
ClientSecret: provider.ClientSecret,
|
|
RedirectURL: provider.RedirectURL,
|
|
Scopes: append([]string{}, provider.Scopes...),
|
|
UsernameClaim: provider.UsernameClaim,
|
|
EmailClaim: provider.EmailClaim,
|
|
CABundle: provider.CABundle,
|
|
}, provider.ProviderID
|
|
}
|
|
|
|
return nil, ""
|
|
}
|
|
|
|
type authContextKey string
|
|
|
|
const (
|
|
adminBypassContextKey authContextKey = "admin_bypass"
|
|
)
|
|
|
|
// InitSessionStore initializes the persistent session store
|
|
func InitSessionStore(dataPath string) {
|
|
_ = ensureSessionStore(dataPath)
|
|
}
|
|
|
|
func ensureSessionStore(dataPath string) *SessionStore {
|
|
newDataPath := strings.TrimSpace(dataPath)
|
|
if newDataPath == "" {
|
|
return nil
|
|
}
|
|
|
|
sessionStoreMu.Lock()
|
|
defer sessionStoreMu.Unlock()
|
|
|
|
if sessionStore != nil && sessionStoreDataPath == newDataPath {
|
|
return sessionStore
|
|
}
|
|
|
|
oldStore := sessionStore
|
|
sessionStore = NewSessionStore(newDataPath)
|
|
sessionStoreDataPath = newDataPath
|
|
if oldStore != nil {
|
|
oldStore.Shutdown()
|
|
}
|
|
return sessionStore
|
|
}
|
|
|
|
func InitPersistentAuthStores(dataPath string) {
|
|
InitSessionStore(dataPath)
|
|
InitCSRFStore(dataPath)
|
|
}
|
|
|
|
// GetSessionStore returns the global session store instance
|
|
func GetSessionStore() *SessionStore {
|
|
sessionStoreMu.Lock()
|
|
store := sessionStore
|
|
sessionStoreMu.Unlock()
|
|
if store == nil {
|
|
panic("session store not initialized; call InitSessionStore with the configured data path first")
|
|
}
|
|
return store
|
|
}
|
|
|
|
func resetSessionStoreForTests() {
|
|
sessionStoreMu.Lock()
|
|
oldStore := sessionStore
|
|
sessionStore = nil
|
|
sessionStoreDataPath = ""
|
|
sessionStoreMu.Unlock()
|
|
if oldStore != nil {
|
|
oldStore.Shutdown()
|
|
}
|
|
}
|
|
|
|
// detectProxy checks if the request is coming through a reverse proxy.
|
|
// Only trusts proxy-set headers when the direct peer is a known trusted proxy
|
|
// to prevent attackers from injecting these headers on direct connections.
|
|
func detectProxy(r *http.Request) bool {
|
|
peerIP := extractRemoteIP(r.RemoteAddr)
|
|
if !isTrustedProxyIP(peerIP) {
|
|
return false
|
|
}
|
|
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.
|
|
// Forwarded-proto headers are only trusted when the direct peer is a known
|
|
// trusted proxy, preventing attackers from injecting X-Forwarded-Proto: https
|
|
// on plain HTTP connections to influence cookie security attributes.
|
|
func isConnectionSecure(r *http.Request) bool {
|
|
if r.TLS != nil {
|
|
return true
|
|
}
|
|
peerIP := extractRemoteIP(r.RemoteAddr)
|
|
if !isTrustedProxyIP(peerIP) {
|
|
return false
|
|
}
|
|
return r.Header.Get("X-Forwarded-Proto") == "https" ||
|
|
strings.Contains(r.Header.Get("Forwarded"), "proto=https")
|
|
}
|
|
|
|
// isWebSocketUpgrade reports whether the request is a WebSocket upgrade handshake.
|
|
// Query-string tokens are only accepted for WebSocket connections because those
|
|
// can't set custom headers during the upgrade. Accepting tokens in the URL for
|
|
// regular HTTP requests would expose them in logs, referrers, and browser history.
|
|
func isWebSocketUpgrade(r *http.Request) bool {
|
|
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
|
|
}
|
|
|
|
// getCookieSettings returns the appropriate cookie settings based on proxy detection.
|
|
//
|
|
// SameSite defaults to Lax for ALL requests, regardless of proxy mode. The
|
|
// previous behaviour auto-escalated to SameSite=None whenever the request
|
|
// arrived via a trusted proxy on https — intended to "be more permissive"
|
|
// for proxied deployments, but in practice that disabled the browser-side
|
|
// CSRF defence for every deployment behind a reverse proxy (which is nearly
|
|
// all of them). SameSite=None tells the browser to send the cookie on
|
|
// arbitrary cross-site requests, which is only required for cross-origin
|
|
// iframe embedding scenarios Pulse does not document or support. Lax
|
|
// continues to send cookies on top-level navigations (the OIDC/SAML
|
|
// callback case), so the proxied-login flow is unaffected.
|
|
//
|
|
// Secure is still set automatically based on the actual connection state
|
|
// (TLS, or X-Forwarded-Proto: https from a trusted proxy).
|
|
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 - cookies use Lax SameSite")
|
|
}
|
|
|
|
return isSecure, http.SameSiteLaxMode
|
|
}
|
|
|
|
// Cookie name constants. The session cookie uses the __Host- prefix when served
|
|
// over HTTPS, which instructs browsers to reject the cookie unless Secure is set,
|
|
// Path is "/", and no Domain attribute is present — preventing cookie injection via
|
|
// related subdomains. The CSRF and org cookies do not use the prefix: the CSRF
|
|
// cookie must be JS-readable for AJAX headers, and the org cookie must be
|
|
// JS-readable for WebSocket org context synchronization.
|
|
const (
|
|
cookieNameSession = "pulse_session"
|
|
cookieNameSessionSecure = "__Host-pulse_session"
|
|
CookieNameCSRF = "pulse_csrf"
|
|
CookieNameOrgID = "pulse_org_id"
|
|
)
|
|
|
|
// sessionCookieName returns the appropriate session cookie name based on whether
|
|
// the connection is secure. When secure, the __Host- prefix is used.
|
|
func sessionCookieName(secure bool) string {
|
|
if secure {
|
|
return cookieNameSessionSecure
|
|
}
|
|
return cookieNameSession
|
|
}
|
|
|
|
// readSessionCookie reads the session cookie from the request. Secure requests
|
|
// must present the __Host- prefixed cookie; insecure requests continue to use
|
|
// the legacy unprefixed name.
|
|
func readSessionCookie(r *http.Request) (*http.Cookie, error) {
|
|
if r == nil {
|
|
return nil, http.ErrNoCookie
|
|
}
|
|
if isConnectionSecure(r) {
|
|
return r.Cookie(cookieNameSessionSecure)
|
|
}
|
|
if c, err := r.Cookie(cookieNameSession); err == nil {
|
|
return c, nil
|
|
}
|
|
return r.Cookie(cookieNameSessionSecure)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
func constantTimeStringEqual(a, b string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
|
}
|
|
|
|
func validateFreshBrowserSession(r *http.Request, username, action string, maxAge time.Duration) *authRequirementFailure {
|
|
if maxAge <= 0 {
|
|
maxAge = privilegedBrowserSessionMaxAge
|
|
}
|
|
action = strings.TrimSpace(action)
|
|
if action == "" {
|
|
action = "continue"
|
|
}
|
|
|
|
cookie, err := readSessionCookie(r)
|
|
if err != nil || strings.TrimSpace(cookie.Value) == "" {
|
|
return &authRequirementFailure{
|
|
Status: http.StatusUnauthorized,
|
|
Code: "fresh_session_required",
|
|
Message: fmt.Sprintf("Sign in again to %s", action),
|
|
}
|
|
}
|
|
if !ValidateSession(cookie.Value) {
|
|
return &authRequirementFailure{
|
|
Status: http.StatusUnauthorized,
|
|
Code: "fresh_session_required",
|
|
Message: fmt.Sprintf("Sign in again to %s", action),
|
|
}
|
|
}
|
|
|
|
session := GetSessionStore().GetSession(cookie.Value)
|
|
if session == nil || strings.TrimSpace(session.Username) == "" || !constantTimeStringEqual(session.Username, username) {
|
|
return &authRequirementFailure{
|
|
Status: http.StatusUnauthorized,
|
|
Code: "fresh_session_required",
|
|
Message: fmt.Sprintf("Sign in again to %s", action),
|
|
}
|
|
}
|
|
if session.CreatedAt.IsZero() || time.Since(session.CreatedAt) > maxAge {
|
|
return &authRequirementFailure{
|
|
Status: http.StatusUnauthorized,
|
|
Code: "fresh_session_required",
|
|
Message: fmt.Sprintf("Sign in again to %s", action),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func requestMatchesRecoverySession(r *http.Request, session *SessionData) bool {
|
|
if r == nil || session == nil || !session.RecoveryBypass || !isDirectLoopbackRequest(r) {
|
|
return false
|
|
}
|
|
expectedIP := normalizeRecoveryBindingIP(session.IP)
|
|
actualIP := normalizeRecoveryBindingIP(GetClientIP(r))
|
|
return expectedIP != "" && actualIP != "" && constantTimeStringEqual(expectedIP, actualIP)
|
|
}
|
|
|
|
func explicitAPITokenFromRequest(r *http.Request) (string, bool) {
|
|
if r == nil {
|
|
return "", false
|
|
}
|
|
|
|
if values := r.Header.Values("X-API-Token"); len(values) > 0 {
|
|
return strings.TrimSpace(values[0]), true
|
|
}
|
|
|
|
if authHeader := r.Header.Get("Authorization"); authHeader != "" && strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
|
return strings.TrimSpace(authHeader[7:]), true
|
|
}
|
|
|
|
if isWebSocketUpgrade(r) {
|
|
if values, ok := r.URL.Query()["token"]; ok && len(values) > 0 {
|
|
return strings.TrimSpace(values[0]), true
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func validateGlobalAPITokenLocked(cfg *config.Config, token string) (*config.APITokenRecord, bool) {
|
|
if cfg == nil || token == "" || !cfg.IsValidAPIToken(token) {
|
|
return nil, false
|
|
}
|
|
|
|
config.Mu.RUnlock()
|
|
config.Mu.Lock()
|
|
record, ok := cfg.ValidateAPIToken(token)
|
|
config.Mu.Unlock()
|
|
config.Mu.RLock()
|
|
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return record, true
|
|
}
|
|
|
|
func validateAPITokenAgainstConfigsLocked(globalCfg, targetCfg *config.Config, token string) (*config.APITokenRecord, bool) {
|
|
if token == "" {
|
|
return nil, false
|
|
}
|
|
|
|
if targetCfg != nil && targetCfg != globalCfg {
|
|
if record, ok := targetCfg.ValidateAPIToken(token); ok {
|
|
return record, true
|
|
}
|
|
}
|
|
|
|
return validateGlobalAPITokenLocked(globalCfg, 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 subtle.ConstantTimeCompare([]byte(proxySecret), []byte(cfg.ProxyAuthSecret)) != 1 {
|
|
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
|
|
}
|
|
|
|
type responseCapture struct {
|
|
http.ResponseWriter
|
|
wrote bool
|
|
}
|
|
|
|
func (rc *responseCapture) WriteHeader(statusCode int) {
|
|
if !rc.wrote {
|
|
rc.wrote = true
|
|
rc.ResponseWriter.WriteHeader(statusCode)
|
|
}
|
|
}
|
|
|
|
func (rc *responseCapture) Write(b []byte) (int, error) {
|
|
rc.wrote = true
|
|
return rc.ResponseWriter.Write(b)
|
|
}
|
|
|
|
func wantsJSONAuthResponse(r *http.Request) bool {
|
|
if r == nil {
|
|
return false
|
|
}
|
|
if r.URL != nil && strings.HasPrefix(r.URL.Path, "/api/") {
|
|
return true
|
|
}
|
|
return strings.Contains(r.Header.Get("Accept"), "application/json")
|
|
}
|
|
|
|
func writeAuthenticationRequired(w http.ResponseWriter, r *http.Request) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
if wantsJSONAuthResponse(r) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"Authentication required"}`))
|
|
return
|
|
}
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
}
|
|
|
|
// CheckAuth checks both basic auth and API token.
|
|
func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool {
|
|
return checkAuth(cfg, w, r, true)
|
|
}
|
|
|
|
func checkAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request, writeDefaultFailure bool) bool {
|
|
// Dev mode bypass for all auth (disabled by default)
|
|
if adminBypassEnabled() {
|
|
if w != nil {
|
|
// Set headers for standard admin user
|
|
w.Header().Set("X-Authenticated-User", "admin")
|
|
w.Header().Set("X-Auth-Method", "bypass")
|
|
}
|
|
return true
|
|
}
|
|
|
|
if cfg == nil {
|
|
path := ""
|
|
if r != nil && r.URL != nil {
|
|
path = r.URL.Path
|
|
}
|
|
log.Error().
|
|
Str("path", path).
|
|
Msg("CheckAuth called without configuration")
|
|
if w != nil {
|
|
http.Error(w, "Authentication unavailable", http.StatusServiceUnavailable)
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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")
|
|
|
|
authenticateToken := func(token string) bool {
|
|
if record, ok := validateGlobalAPITokenLocked(cfg, token); ok {
|
|
attachAPITokenRecord(r, record)
|
|
if authenticatedUser := apiTokenAuthenticatedUser(record); authenticatedUser != "" {
|
|
w.Header().Set("X-Authenticated-User", authenticatedUser)
|
|
}
|
|
w.Header().Set("X-Auth-Method", "api_token")
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Explicit token credentials always take precedence over session/basic auth.
|
|
if cfg.HasAPITokens() {
|
|
if providedToken, provided := explicitAPITokenFromRequest(r); provided {
|
|
if authenticateToken(providedToken) {
|
|
return true
|
|
}
|
|
if w != nil {
|
|
http.Error(w, "Invalid API token", http.StatusUnauthorized)
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check session cookie (for WebSocket and UI). Hosted cloud-handoff and
|
|
// other browser-session flows must stay authoritative even when the runtime
|
|
// also has API tokens configured.
|
|
if cookie, err := readSessionCookie(r); err == nil && cookie.Value != "" {
|
|
session := GetSessionStore().GetSession(cookie.Value)
|
|
if session != nil && session.RecoveryBypass {
|
|
if requestMatchesRecoverySession(r, session) && ValidateAndExtendSession(cookie.Value) {
|
|
if session.Username != "" {
|
|
w.Header().Set("X-Authenticated-User", session.Username)
|
|
}
|
|
w.Header().Set("X-Auth-Method", "recovery_session")
|
|
w.Header().Set("X-Auth-Recovery", "true")
|
|
return true
|
|
}
|
|
log.Warn().
|
|
Str("path", r.URL.Path).
|
|
Str("client_ip", GetClientIP(r)).
|
|
Str("session_ip", session.IP).
|
|
Msg("Rejected recovery session outside direct loopback binding")
|
|
} else if ValidateAndExtendSession(cookie.Value) {
|
|
username := GetSessionUsername(cookie.Value)
|
|
if session != nil && session.OIDCRefreshToken != "" && hasEnabledSSOProvidersForAuth(cfg) {
|
|
// Check if access token is expired or about to expire (5 min buffer)
|
|
if time.Now().Add(5 * time.Minute).After(session.OIDCAccessTokenExp) {
|
|
go refreshOIDCSessionTokens(cfg, cookie.Value, session)
|
|
}
|
|
}
|
|
if username != "" {
|
|
w.Header().Set("X-Authenticated-User", username)
|
|
}
|
|
if session != nil && strings.TrimSpace(session.OIDCIssuer) != "" {
|
|
w.Header().Set("X-Auth-Method", "oidc")
|
|
} else {
|
|
w.Header().Set("X-Auth-Method", "session")
|
|
}
|
|
return true
|
|
}
|
|
// Debug logging for failed session validation
|
|
log.Debug().
|
|
Str("session_token", safePrefixForLog(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")
|
|
}
|
|
|
|
// API-only mode: when only API tokens are configured (no password auth),
|
|
// explicit token credentials still win above and a valid session still wins
|
|
// above for hosted/cloud-handoff browser flows. If neither is present, fail
|
|
// closed and require a token.
|
|
if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.HasAPITokens() {
|
|
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
|
|
}
|
|
|
|
// If no auth is configured at all, allow access unless SSO is enabled.
|
|
// A valid session still wins above, so hosted/cloud-handoff browser sessions
|
|
// do not get flattened into anonymous access.
|
|
if cfg.AuthUser == "" && cfg.AuthPass == "" && !cfg.HasAPITokens() && cfg.ProxyAuthSecret == "" {
|
|
if hasEnabledSSOProvidersForAuth(cfg) {
|
|
log.Debug().Msg("SSO enabled without local credentials, authentication required")
|
|
} else {
|
|
if isDirectLoopbackRequest(r) {
|
|
log.Debug().Msg("No auth configured, allowing loopback access as 'anonymous'")
|
|
if w != nil {
|
|
w.Header().Set("X-Authenticated-User", "anonymous")
|
|
w.Header().Set("X-Auth-Method", "none")
|
|
}
|
|
return true
|
|
}
|
|
log.Warn().
|
|
Str("path", r.URL.Path).
|
|
Str("ip", GetClientIP(r)).
|
|
Msg("Rejected non-loopback access before auth was configured")
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "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")
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "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 := constantTimeStringEqual(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 {
|
|
// Invalidate any pre-existing session to prevent session fixation attacks.
|
|
InvalidateOldSessionFromRequest(r)
|
|
|
|
token := generateSessionToken()
|
|
if token == "" {
|
|
return false
|
|
}
|
|
|
|
// Store session persistently (including username for restart survival)
|
|
userAgent := r.Header.Get("User-Agent")
|
|
clientIP := GetClientIP(r)
|
|
GetSessionStore().CreateSession(token, 24*time.Hour, userAgent, clientIP, parts[0])
|
|
|
|
// Track session for user (in-memory for fast lookups)
|
|
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", safePrefixForLog(token, 8)+"...").
|
|
Str("remote_addr", r.RemoteAddr).
|
|
Msg("Setting session cookie after successful login")
|
|
|
|
// Set session cookie
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName(isSecure),
|
|
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: CookieNameCSRF,
|
|
Value: csrfToken,
|
|
Path: "/",
|
|
Secure: isSecure,
|
|
SameSite: sameSitePolicy,
|
|
MaxAge: 86400, // 24 hours
|
|
})
|
|
|
|
// Audit log successful login
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "login", parts[0], GetClientIP(r), r.URL.Path, true, "Basic auth login")
|
|
}
|
|
w.Header().Set("X-Authenticated-User", parts[0])
|
|
w.Header().Set("X-Auth-Method", "basic")
|
|
return true
|
|
} else {
|
|
// Failed login
|
|
RecordFailedLogin(parts[0])
|
|
RecordFailedLogin(clientIP)
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if writeDefaultFailure {
|
|
writeAuthenticationRequired(w, r)
|
|
}
|
|
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
|
|
}
|
|
|
|
authWriter := &responseCapture{ResponseWriter: w}
|
|
if checkAuth(cfg, authWriter, r, false) {
|
|
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")
|
|
|
|
if !authWriter.wrote {
|
|
// Never send WWW-Authenticate header - we want to use our custom login page.
|
|
// The frontend will detect 401 responses and show the login component.
|
|
writeAuthenticationRequired(w, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RequireAdmin middleware checks for authentication and admin privileges.
|
|
// Proxy-auth users must have the configured admin role. Session/OIDC users
|
|
// must match the configured admin identity.
|
|
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
|
|
authWriter := &responseCapture{ResponseWriter: w}
|
|
if !checkAuth(cfg, authWriter, r, false) {
|
|
// Log the failed attempt
|
|
log.Warn().
|
|
Str("ip", r.RemoteAddr).
|
|
Str("path", r.URL.Path).
|
|
Str("method", r.Method).
|
|
Msg("Unauthorized access attempt")
|
|
|
|
if !authWriter.wrote {
|
|
writeAuthenticationRequired(w, r)
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Enforce configured admin identity for session-based auth.
|
|
if !ensureAdminSession(cfg, w, r) {
|
|
return
|
|
}
|
|
|
|
// User is authenticated and has admin privileges.
|
|
handler(w, r)
|
|
}
|
|
}
|
|
|
|
// RequirePermission middleware checks for authentication and specific RBAC permissions
|
|
func RequirePermission(cfg *config.Config, authorizer auth.Authorizer, action, resource string, next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// First check if user is authenticated (using RequireAdmin logic as base)
|
|
authWriter := &responseCapture{ResponseWriter: w}
|
|
if !checkAuth(cfg, authWriter, r, false) {
|
|
if !authWriter.wrote {
|
|
if wantsJSONAuthResponse(r) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte(`{"error":"authentication_required","message":"Authentication required"}`))
|
|
} else {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Check if using proxy auth and if so, verify admin status.
|
|
// When a real RBAC authorizer is active (non-DefaultAuthorizer), non-admin
|
|
// proxy users are allowed through to the RBAC check below, which may grant
|
|
// access based on their role assignments. Without RBAC, non-admin proxy
|
|
// users are hard-rejected since there's no other authorization mechanism.
|
|
if cfg.ProxyAuthSecret != "" {
|
|
if valid, username, isAdmin := CheckProxyAuth(cfg, r); valid {
|
|
if !isAdmin {
|
|
// Check if a real RBAC authorizer is active
|
|
_, isDefaultAuth := authorizer.(*internalauth.DefaultAuthorizer)
|
|
if isDefaultAuth {
|
|
// No RBAC: non-admin proxy users are rejected
|
|
log.Warn().
|
|
Str("ip", r.RemoteAddr).
|
|
Str("path", r.URL.Path).
|
|
Str("action", action).
|
|
Str("resource", resource).
|
|
Str("username", username).
|
|
Msg("Non-admin proxy user attempted to access permissioned endpoint (no RBAC active)")
|
|
|
|
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
|
|
}
|
|
// RBAC active: defer to authorizer check below
|
|
log.Debug().
|
|
Str("username", username).
|
|
Str("action", action).
|
|
Str("resource", resource).
|
|
Msg("Non-admin proxy user deferred to RBAC authorizer")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Take the authenticated identity from the REQUEST context, not the
|
|
// response header. checkAuth above (and every other auth path
|
|
// upstream) attaches the username via attachUserContext, which
|
|
// stores it on the request context. Reading from w.Header() — a
|
|
// mutable response header that any handler between checkAuth and
|
|
// RequirePermission can overwrite — would let a downstream
|
|
// middleware substitute an arbitrary username and have the RBAC
|
|
// authorizer make its decision against that value. Response
|
|
// headers are not an identity channel.
|
|
ctx := r.Context()
|
|
username := internalauth.GetUser(ctx)
|
|
if username == "" {
|
|
// Defensive fallback: if some upstream auth path set the
|
|
// header but skipped attachUserContext, surface that here so
|
|
// the authorizer still gets the right identity, but log it so
|
|
// the gap can be plugged at the source.
|
|
if headerUser := w.Header().Get("X-Authenticated-User"); headerUser != "" {
|
|
log.Warn().
|
|
Str("user", headerUser).
|
|
Str("path", r.URL.Path).
|
|
Msg("Authenticated user found only in response header; upstream auth path should attachUserContext")
|
|
username = headerUser
|
|
ctx = internalauth.WithUser(ctx, username)
|
|
}
|
|
}
|
|
|
|
// Check permission via authorizer
|
|
allowed, err := authorizer.Authorize(ctx, action, resource)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("user", username).Str("action", action).Str("resource", resource).Msg("RBAC authorization failed due to system 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.StatusInternalServerError)
|
|
w.Write([]byte(`{"error":"internal_error","message":"Failed to verify permissions"}`))
|
|
} else {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
if !allowed {
|
|
log.Warn().
|
|
Str("user", username).
|
|
Str("ip", r.RemoteAddr).
|
|
Str("path", r.URL.Path).
|
|
Str("action", action).
|
|
Str("resource", resource).
|
|
Msg("Forbidden access attempt (RBAC)")
|
|
|
|
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)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"error": "forbidden",
|
|
"message": "You do not have permission to perform this action",
|
|
"action": action,
|
|
"resource": resource,
|
|
})
|
|
} else {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
}
|
|
return
|
|
}
|
|
|
|
next(w, r.WithContext(ctx))
|
|
}
|
|
}
|
|
|
|
// 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 RequireAnyScope([]string{scope}, handler)
|
|
}
|
|
|
|
// RequireAnyScope ensures that token-authenticated requests include at least
|
|
// one of the specified scopes. Session-based (browser) requests bypass the
|
|
// scope check.
|
|
func RequireAnyScope(scopes []string, handler http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !ensureAnyScope(w, r, scopes...) {
|
|
return
|
|
}
|
|
handler(w, r)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return ensureAnyScope(w, r, scope)
|
|
}
|
|
|
|
// ensureAnyScope enforces that the request either originates from a session or
|
|
// a token possessing at least one of the specified scopes.
|
|
func ensureAnyScope(w http.ResponseWriter, r *http.Request, scopes ...string) bool {
|
|
normalized := make([]string, 0, len(scopes))
|
|
for _, scope := range scopes {
|
|
scope = strings.TrimSpace(scope)
|
|
if scope == "" {
|
|
return true
|
|
}
|
|
normalized = append(normalized, scope)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return true
|
|
}
|
|
|
|
record := getAPITokenRecordFromRequest(r)
|
|
if record == nil {
|
|
return true
|
|
}
|
|
for _, scope := range normalized {
|
|
if record.HasScope(scope) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if len(normalized) == 1 {
|
|
respondMissingScope(w, normalized[0])
|
|
return false
|
|
}
|
|
if w != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"error": "missing_scope",
|
|
"requiredScopes": normalized,
|
|
})
|
|
}
|
|
return false
|
|
}
|
|
|
|
func attachAPITokenRecord(r *http.Request, record *config.APITokenRecord) {
|
|
if record == nil {
|
|
return
|
|
}
|
|
clone := record.Clone()
|
|
ctx := internalauth.WithAPIToken(r.Context(), &clone)
|
|
*r = *r.WithContext(ctx)
|
|
}
|
|
|
|
// attachUserContext stores the authenticated username in the request context.
|
|
func attachUserContext(r *http.Request, username string) *http.Request {
|
|
if username == "" {
|
|
return r
|
|
}
|
|
ctx := internalauth.WithUser(r.Context(), username)
|
|
return r.WithContext(ctx)
|
|
}
|
|
|
|
func attachAdminBypassContext(r *http.Request) *http.Request {
|
|
ctx := context.WithValue(r.Context(), adminBypassContextKey, true)
|
|
return r.WithContext(ctx)
|
|
}
|
|
|
|
func isAdminBypassRequest(ctx context.Context) bool {
|
|
bypass, ok := ctx.Value(adminBypassContextKey).(bool)
|
|
return ok && bypass
|
|
}
|
|
|
|
// AuthContextMiddleware creates a middleware that extracts auth info and stores it in context.
|
|
// This should run early in the middleware chain so subsequent middleware can access auth context.
|
|
// Note: This middleware does NOT enforce authentication - it only populates context.
|
|
// Use RequireAuth for enforcement.
|
|
// AuthContextMiddleware creates a middleware that extracts auth info and stores it in context.
|
|
// This should run early in the middleware chain so subsequent middleware can access auth context.
|
|
// Note: This middleware does NOT enforce authentication - it only populates context.
|
|
// Use RequireAuth for enforcement.
|
|
func AuthContextMiddleware(cfg *config.Config, mtm *monitoring.MultiTenantMonitor, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Try to extract auth info and store in context WITHOUT enforcing auth
|
|
// This allows tenant middleware to check authorization later
|
|
r = extractAndStoreAuthContext(cfg, mtm, r)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// extractAndStoreAuthContext extracts user/token info from the request and stores in context.
|
|
// Returns the request with updated context. Does not enforce auth.
|
|
func extractAndStoreAuthContext(cfg *config.Config, mtm *monitoring.MultiTenantMonitor, r *http.Request) *http.Request {
|
|
// Use RLock for common case, upgrade to Lock only if we need to update token stats
|
|
config.Mu.RLock()
|
|
defer config.Mu.RUnlock()
|
|
|
|
// Dev mode bypass
|
|
if adminBypassEnabled() {
|
|
return attachAdminBypassContext(attachUserContext(r, "admin"))
|
|
}
|
|
|
|
// Check proxy auth
|
|
if cfg.ProxyAuthSecret != "" {
|
|
if valid, username, _ := CheckProxyAuth(cfg, r); valid && username != "" {
|
|
return attachUserContext(r, username)
|
|
}
|
|
}
|
|
|
|
// Check API tokens
|
|
// Check API tokens
|
|
// We need to check if EITHER the global config has tokens OR if we have a tenant monitor (which might have tokens)
|
|
if cfg.HasAPITokens() || mtm != nil {
|
|
// Determine which config to use for validation (Global vs Tenant)
|
|
targetConfig := cfg
|
|
|
|
if mtm != nil {
|
|
// Check for Tenant ID in header or cookie
|
|
orgID := "default"
|
|
if headerOrgID := r.Header.Get("X-Pulse-Org-ID"); headerOrgID != "" {
|
|
orgID = headerOrgID
|
|
} else if cookie, err := r.Cookie(CookieNameOrgID); err == nil && cookie.Value != "" {
|
|
orgID = cookie.Value
|
|
}
|
|
|
|
// If targeting a specific tenant, try to load that tenant's config
|
|
if orgID != "default" {
|
|
// Prevent DoS: Check if org exists before loading (which triggers directory creation)
|
|
if mtm.OrgExists(orgID) {
|
|
if m, err := mtm.GetMonitor(orgID); err == nil && m != nil {
|
|
targetConfig = m.GetConfig()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
validateToken := func(token string) (*http.Request, bool) {
|
|
if record, ok := validateAPITokenAgainstConfigsLocked(cfg, targetConfig, token); ok {
|
|
attachAPITokenRecord(r, record)
|
|
return attachUserContext(r, apiTokenAuthenticatedUser(record)), true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
if providedToken, provided := explicitAPITokenFromRequest(r); provided {
|
|
if req, ok := validateToken(providedToken); ok {
|
|
return req
|
|
}
|
|
return r
|
|
}
|
|
}
|
|
|
|
// Check session cookie
|
|
if cookie, err := readSessionCookie(r); err == nil && cookie.Value != "" {
|
|
if ValidateSession(cookie.Value) {
|
|
if username := GetSessionUsername(cookie.Value); username != "" {
|
|
return attachUserContext(r, username)
|
|
}
|
|
}
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
func getAPITokenRecordFromRequest(r *http.Request) *config.APITokenRecord {
|
|
val := internalauth.GetAPIToken(r.Context())
|
|
if val == nil {
|
|
return nil
|
|
}
|
|
record, ok := val.(*config.APITokenRecord)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
clone := record.Clone()
|
|
return &clone
|
|
}
|
|
func adminBypassEnabled() bool {
|
|
adminBypassState.once.Do(func() {
|
|
adminBypassState.enabled, adminBypassState.declined = resolveAdminBypassEnv()
|
|
if adminBypassState.enabled {
|
|
log.Warn().Msg("Admin authentication bypass ENABLED (development mode)")
|
|
return
|
|
}
|
|
if adminBypassState.declined {
|
|
log.Warn().Msg("Ignoring ALLOW_ADMIN_BYPASS outside development mode")
|
|
}
|
|
})
|
|
return adminBypassState.enabled
|
|
}
|
|
|
|
// oidcRefreshMutex prevents concurrent refresh attempts for the same session
|
|
var oidcRefreshMutex sync.Map
|
|
|
|
// refreshOIDCSessionTokens refreshes OIDC tokens for a session in the background
|
|
// If refresh fails, the session is invalidated and the user will need to re-login
|
|
func refreshOIDCSessionTokens(cfg *config.Config, sessionToken string, session *SessionData) {
|
|
// Prevent concurrent refresh attempts for the same session
|
|
if _, loaded := oidcRefreshMutex.LoadOrStore(sessionToken, true); loaded {
|
|
return // Another goroutine is already refreshing this session
|
|
}
|
|
defer oidcRefreshMutex.Delete(sessionToken)
|
|
|
|
// Mark session as refreshing to prevent duplicate attempts
|
|
GetSessionStore().SetTokenRefreshing(sessionToken, true)
|
|
defer GetSessionStore().SetTokenRefreshing(sessionToken, false)
|
|
|
|
log.Debug().
|
|
Str("issuer", session.OIDCIssuer).
|
|
Time("token_expiry", session.OIDCAccessTokenExp).
|
|
Msg("Attempting OIDC token refresh")
|
|
|
|
// Create a context with timeout for the refresh operation
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
// Resolve OIDC provider config from v6 enabled SSO providers.
|
|
oidcCfg, providerID := resolveOIDCRefreshConfig(cfg, session)
|
|
if oidcCfg == nil {
|
|
// Session may belong to a disabled/removed provider. Skip refresh silently;
|
|
// the session continues until natural expiry.
|
|
log.Debug().Msg("No matching enabled SSO OIDC provider for session refresh")
|
|
return
|
|
}
|
|
|
|
// Create a temporary OIDC service for refreshing
|
|
service, err := NewOIDCService(ctx, oidcCfg)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to create OIDC service for token refresh")
|
|
return
|
|
}
|
|
|
|
// Attempt to refresh the token
|
|
result, err := service.RefreshToken(ctx, session.OIDCRefreshToken)
|
|
if err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("issuer", session.OIDCIssuer).
|
|
Str("provider_id", providerID).
|
|
Msg("OIDC token refresh failed - invalidating session")
|
|
|
|
// Token refresh failed - this usually means the refresh token was revoked
|
|
// or expired. Invalidate the session to force re-login.
|
|
GetSessionStore().InvalidateSession(sessionToken)
|
|
LogAuditEvent("oidc_token_refresh", "", "", "", false, "Token refresh failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Update the session with new tokens
|
|
GetSessionStore().UpdateOIDCTokens(sessionToken, result.RefreshToken, result.Expiry)
|
|
|
|
log.Info().
|
|
Time("new_expiry", result.Expiry).
|
|
Str("provider_id", providerID).
|
|
Msg("OIDC token refresh successful - session extended")
|
|
|
|
LogAuditEvent("oidc_token_refresh", "", "", "", true, "Token refreshed successfully")
|
|
}
|