package mining import ( "crypto/md5" "crypto/rand" "crypto/subtle" "encoding/hex" "fmt" "net/http" "os" "strings" "sync" "time" "github.com/Snider/Mining/pkg/logging" "github.com/gin-gonic/gin" ) // AuthConfig holds authentication configuration type AuthConfig struct { // Enabled determines if authentication is required Enabled bool // Username for basic/digest auth Username string // Password for basic/digest auth Password string // Realm for digest auth Realm string // NonceExpiry is how long a nonce is valid NonceExpiry time.Duration } // DefaultAuthConfig returns the default auth configuration. // Auth is disabled by default for local development. func DefaultAuthConfig() AuthConfig { return AuthConfig{ Enabled: false, Username: "", Password: "", Realm: "Mining API", NonceExpiry: 5 * time.Minute, } } // AuthConfigFromEnv creates auth config from environment variables. // Set MINING_API_AUTH=true to enable, MINING_API_USER and MINING_API_PASS for credentials. func AuthConfigFromEnv() AuthConfig { config := DefaultAuthConfig() if os.Getenv("MINING_API_AUTH") == "true" { config.Enabled = true config.Username = os.Getenv("MINING_API_USER") config.Password = os.Getenv("MINING_API_PASS") if config.Username == "" || config.Password == "" { logging.Warn("API auth enabled but credentials not set", logging.Fields{ "hint": "Set MINING_API_USER and MINING_API_PASS environment variables", }) config.Enabled = false } } if realm := os.Getenv("MINING_API_REALM"); realm != "" { config.Realm = realm } return config } // DigestAuth implements HTTP Digest Authentication middleware type DigestAuth struct { config AuthConfig nonces sync.Map // map[string]time.Time for nonce expiry tracking stopChan chan struct{} stopOnce sync.Once } // NewDigestAuth creates a new digest auth middleware func NewDigestAuth(config AuthConfig) *DigestAuth { da := &DigestAuth{ config: config, stopChan: make(chan struct{}), } // Start nonce cleanup goroutine go da.cleanupNonces() return da } // Stop gracefully shuts down the DigestAuth, stopping the cleanup goroutine. // Safe to call multiple times. func (da *DigestAuth) Stop() { da.stopOnce.Do(func() { close(da.stopChan) }) } // Middleware returns a Gin middleware that enforces digest authentication func (da *DigestAuth) Middleware() gin.HandlerFunc { return func(c *gin.Context) { if !da.config.Enabled { c.Next() return } authHeader := c.GetHeader("Authorization") if authHeader == "" { da.sendChallenge(c) return } // Try digest auth first if strings.HasPrefix(authHeader, "Digest ") { if da.validateDigest(c, authHeader) { c.Next() return } da.sendChallenge(c) return } // Fall back to basic auth if strings.HasPrefix(authHeader, "Basic ") { if da.validateBasic(c, authHeader) { c.Next() return } } da.sendChallenge(c) } } // sendChallenge sends a 401 response with digest auth challenge func (da *DigestAuth) sendChallenge(c *gin.Context) { nonce := da.generateNonce() da.nonces.Store(nonce, time.Now()) challenge := fmt.Sprintf( `Digest realm="%s", qop="auth", nonce="%s", opaque="%s"`, da.config.Realm, nonce, da.generateOpaque(), ) c.Header("WWW-Authenticate", challenge) c.AbortWithStatusJSON(http.StatusUnauthorized, APIError{ Code: "AUTH_REQUIRED", Message: "Authentication required", Suggestion: "Provide valid credentials using Digest or Basic authentication", }) } // validateDigest validates a digest auth header func (da *DigestAuth) validateDigest(c *gin.Context, authHeader string) bool { params := parseDigestParams(authHeader[7:]) // Skip "Digest " nonce := params["nonce"] if nonce == "" { return false } // Check nonce validity if storedTime, ok := da.nonces.Load(nonce); ok { if time.Since(storedTime.(time.Time)) > da.config.NonceExpiry { da.nonces.Delete(nonce) return false } } else { return false } // Validate username with constant-time comparison to prevent timing attacks if subtle.ConstantTimeCompare([]byte(params["username"]), []byte(da.config.Username)) != 1 { return false } // Calculate expected response ha1 := md5Hash(fmt.Sprintf("%s:%s:%s", da.config.Username, da.config.Realm, da.config.Password)) ha2 := md5Hash(fmt.Sprintf("%s:%s", c.Request.Method, params["uri"])) var expectedResponse string if params["qop"] == "auth" { expectedResponse = md5Hash(fmt.Sprintf("%s:%s:%s:%s:%s:%s", ha1, nonce, params["nc"], params["cnonce"], params["qop"], ha2)) } else { expectedResponse = md5Hash(fmt.Sprintf("%s:%s:%s", ha1, nonce, ha2)) } // Constant-time comparison to prevent timing attacks return subtle.ConstantTimeCompare([]byte(expectedResponse), []byte(params["response"])) == 1 } // validateBasic validates a basic auth header func (da *DigestAuth) validateBasic(c *gin.Context, authHeader string) bool { // Gin has built-in basic auth, but we do manual validation for consistency user, pass, ok := c.Request.BasicAuth() if !ok { return false } // Constant-time comparison to prevent timing attacks userMatch := subtle.ConstantTimeCompare([]byte(user), []byte(da.config.Username)) == 1 passMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(da.config.Password)) == 1 return userMatch && passMatch } // generateNonce creates a cryptographically random nonce func (da *DigestAuth) generateNonce() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { // Cryptographic failure is critical - fall back to time-based nonce // This should never happen on a properly configured system return hex.EncodeToString([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) } return hex.EncodeToString(b) } // generateOpaque creates an opaque value func (da *DigestAuth) generateOpaque() string { return md5Hash(da.config.Realm) } // cleanupNonces removes expired nonces periodically func (da *DigestAuth) cleanupNonces() { interval := da.config.NonceExpiry if interval <= 0 { interval = 5 * time.Minute // Default if not set } ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-da.stopChan: return case <-ticker.C: now := time.Now() da.nonces.Range(func(key, value interface{}) bool { if now.Sub(value.(time.Time)) > da.config.NonceExpiry { da.nonces.Delete(key) } return true }) } } } // parseDigestParams parses the parameters from a digest auth header func parseDigestParams(header string) map[string]string { params := make(map[string]string) parts := strings.Split(header, ",") for _, part := range parts { part = strings.TrimSpace(part) idx := strings.Index(part, "=") if idx < 0 { continue } key := strings.TrimSpace(part[:idx]) value := strings.TrimSpace(part[idx+1:]) // Remove quotes value = strings.Trim(value, `"`) params[key] = value } return params } // md5Hash returns the MD5 hash of a string as a hex string func md5Hash(s string) string { h := md5.Sum([]byte(s)) return hex.EncodeToString(h[:]) }