package mining import ( "crypto/md5" "crypto/rand" "crypto/subtle" "encoding/hex" "net/http" "os" "strconv" "strings" "sync" "time" "forge.lthn.ai/Snider/Mining/pkg/logging" "github.com/gin-gonic/gin" ) // AuthConfig{Enabled: true, Username: "admin", Password: "secret", Realm: "Mining API"} type AuthConfig struct { Enabled bool Username string Password string Realm string NonceExpiry time.Duration } // authConfig := DefaultAuthConfig() // Enabled: false, Realm: "Mining API", NonceExpiry: 5m func DefaultAuthConfig() AuthConfig { return AuthConfig{ Enabled: false, Username: "", Password: "", Realm: "Mining API", NonceExpiry: 5 * time.Minute, } } // authConfig := AuthConfigFromEnv() // reads MINING_API_AUTH, MINING_API_USER, MINING_API_PASS, MINING_API_REALM 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 := NewDigestAuth(authConfig); router.Use(digestAuth.Middleware()); defer digestAuth.Stop() type DigestAuth struct { config AuthConfig nonces sync.Map // map[string]time.Time for nonce expiry tracking stopChan chan struct{} stopOnce sync.Once } // digestAuth := NewDigestAuth(AuthConfigFromEnv()); router.Use(digestAuth.Middleware()) func NewDigestAuth(config AuthConfig) *DigestAuth { digestAuth := &DigestAuth{ config: config, stopChan: make(chan struct{}), } // Start nonce cleanup goroutine go digestAuth.cleanupNonces() return digestAuth } // defer digestAuth.Stop() // safe to call multiple times; stops the nonce cleanup goroutine func (digestAuth *DigestAuth) Stop() { digestAuth.stopOnce.Do(func() { close(digestAuth.stopChan) }) } // router.Use(digestAuth.Middleware()) // enforces Digest or Basic auth on all routes func (digestAuth *DigestAuth) Middleware() gin.HandlerFunc { return func(c *gin.Context) { if !digestAuth.config.Enabled { c.Next() return } authHeader := c.GetHeader("Authorization") if authHeader == "" { digestAuth.sendChallenge(c) return } // Try digest auth first if strings.HasPrefix(authHeader, "Digest ") { if digestAuth.validateDigest(c, authHeader) { c.Next() return } digestAuth.sendChallenge(c) return } // Fall back to basic auth if strings.HasPrefix(authHeader, "Basic ") { if digestAuth.validateBasic(c, authHeader) { c.Next() return } } digestAuth.sendChallenge(c) } } // digestAuth.sendChallenge(c) // writes WWW-Authenticate header and 401 JSON response func (digestAuth *DigestAuth) sendChallenge(c *gin.Context) { nonce := digestAuth.generateNonce() digestAuth.nonces.Store(nonce, time.Now()) challenge := `Digest realm="` + digestAuth.config.Realm + `", qop="auth", nonce="` + nonce + `", opaque="` + digestAuth.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", }) } // valid := digestAuth.validateDigest(c, c.GetHeader("Authorization")) func (digestAuth *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 := digestAuth.nonces.Load(nonce); ok { if time.Since(storedTime.(time.Time)) > digestAuth.config.NonceExpiry { digestAuth.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(digestAuth.config.Username)) != 1 { return false } // Calculate expected response hashA1 := md5Hash(digestAuth.config.Username + ":" + digestAuth.config.Realm + ":" + digestAuth.config.Password) hashA2 := md5Hash(c.Request.Method + ":" + params["uri"]) var expectedResponse string if params["qop"] == "auth" { expectedResponse = md5Hash(hashA1 + ":" + nonce + ":" + params["nc"] + ":" + params["cnonce"] + ":" + params["qop"] + ":" + hashA2) } else { expectedResponse = md5Hash(hashA1 + ":" + nonce + ":" + hashA2) } // Constant-time comparison to prevent timing attacks return subtle.ConstantTimeCompare([]byte(expectedResponse), []byte(params["response"])) == 1 } // valid := digestAuth.validateBasic(c, c.GetHeader("Authorization")) func (digestAuth *DigestAuth) validateBasic(c *gin.Context, authHeader string) bool { // Gin has built-in basic auth, but we do manual validation for consistency username, password, ok := c.Request.BasicAuth() if !ok { return false } userMatch := subtle.ConstantTimeCompare([]byte(username), []byte(digestAuth.config.Username)) == 1 passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(digestAuth.config.Password)) == 1 return userMatch && passwordMatch } // nonce := digestAuth.generateNonce() // 32-char hex string, cryptographically random func (digestAuth *DigestAuth) generateNonce() string { randomBytes := make([]byte, 16) if _, err := rand.Read(randomBytes); 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(strconv.FormatInt(time.Now().UnixNano(), 10))) } return hex.EncodeToString(randomBytes) } // opaque := digestAuth.generateOpaque() // MD5 of realm, stable per auth instance func (digestAuth *DigestAuth) generateOpaque() string { return md5Hash(digestAuth.config.Realm) } // go digestAuth.cleanupNonces() // runs until stopChan is closed; interval = NonceExpiry func (digestAuth *DigestAuth) cleanupNonces() { interval := digestAuth.config.NonceExpiry if interval <= 0 { interval = 5 * time.Minute // Default if not set } ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-digestAuth.stopChan: return case <-ticker.C: now := time.Now() digestAuth.nonces.Range(func(key, value interface{}) bool { if now.Sub(value.(time.Time)) > digestAuth.config.NonceExpiry { digestAuth.nonces.Delete(key) } return true }) } } } // params := parseDigestParams(authHeader[7:]) // {"nonce": "abc", "uri": "/api", "qop": "auth"} 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 } // hash := md5Hash("user:realm:pass") // "5f4dcc3b5aa765d61d8327deb882cf99" func md5Hash(input string) string { digest := md5.Sum([]byte(input)) return hex.EncodeToString(digest[:]) }