Mining/pkg/mining/auth.go
Claude b12db10680
ax(batch): expand abbreviated parameter and local variable names across all packages
Applies AX principle 1 (Predictable Names Over Short Names) to function
signatures and local variables: s->input/raw, v->target/value, d->duration,
a,b->left,right, w->writer, r->reader, l->logger, p->part/databasePoint,
fn parameter names left as-is where they are callback conventions.

Co-Authored-By: Charon <charon@lethean.io>
2026-04-02 18:27:21 +01:00

260 lines
7.3 KiB
Go

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
user, pass, ok := c.Request.BasicAuth()
if !ok {
return false
}
// Constant-time comparison to prevent timing attacks
userMatch := subtle.ConstantTimeCompare([]byte(user), []byte(digestAuth.config.Username)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(digestAuth.config.Password)) == 1
return userMatch && passMatch
}
// 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
}
// h := md5Hash("user:realm:pass") // "5f4dcc3b5aa765d61d8327deb882cf99"
func md5Hash(input string) string {
digest := md5.Sum([]byte(input))
return hex.EncodeToString(digest[:])
}