Mining/pkg/mining/auth.go
Claude cc8eeab231
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
ax(mining): replace prose comments in auth.go with usage examples
All comments on AuthConfig, DefaultAuthConfig, AuthConfigFromEnv,
DigestAuth, NewDigestAuth, Stop, Middleware, and all private methods
were restating the type signature in prose. Replaced with concrete
call-site examples per AX Principle 2 (comments as usage examples).

Co-Authored-By: Charon <charon@lethean.io>
2026-04-02 09:29:03 +01:00

266 lines
6.9 KiB
Go

package mining
import (
"crypto/md5"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"os"
"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
}
// cfg := DefaultAuthConfig() // Enabled: false, Realm: "Mining API", NonceExpiry: 5m
func DefaultAuthConfig() AuthConfig {
return AuthConfig{
Enabled: false,
Username: "",
Password: "",
Realm: "Mining API",
NonceExpiry: 5 * time.Minute,
}
}
// cfg := 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
}
// da := NewDigestAuth(cfg); router.Use(da.Middleware()); defer da.Stop()
type DigestAuth struct {
config AuthConfig
nonces sync.Map // map[string]time.Time for nonce expiry tracking
stopChan chan struct{}
stopOnce sync.Once
}
// da := NewDigestAuth(AuthConfigFromEnv()); router.Use(da.Middleware())
func NewDigestAuth(config AuthConfig) *DigestAuth {
da := &DigestAuth{
config: config,
stopChan: make(chan struct{}),
}
// Start nonce cleanup goroutine
go da.cleanupNonces()
return da
}
// defer da.Stop() // safe to call multiple times; stops the nonce cleanup goroutine
func (da *DigestAuth) Stop() {
da.stopOnce.Do(func() {
close(da.stopChan)
})
}
// router.Use(da.Middleware()) // enforces Digest or Basic auth on all routes
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)
}
}
// da.sendChallenge(c) // writes WWW-Authenticate header and 401 JSON response
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",
})
}
// ok := da.validateDigest(c, c.GetHeader("Authorization"))
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
}
// ok := da.validateBasic(c, c.GetHeader("Authorization"))
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
}
// nonce := da.generateNonce() // 32-char hex string, cryptographically random
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)
}
// opaque := da.generateOpaque() // MD5 of realm, stable per auth instance
func (da *DigestAuth) generateOpaque() string {
return md5Hash(da.config.Realm)
}
// go da.cleanupNonces() // runs until stopChan is closed; interval = NonceExpiry
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
})
}
}
}
// 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
}
// h := md5Hash("user:realm:pass") // "5f4dcc3b5aa765d61d8327deb882cf99"
func md5Hash(s string) string {
h := md5.Sum([]byte(s))
return hex.EncodeToString(h[:])
}