259 lines
7.7 KiB
Go
259 lines
7.7 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 {
|
|
authConfig := DefaultAuthConfig()
|
|
|
|
if os.Getenv("MINING_API_AUTH") == "true" {
|
|
authConfig.Enabled = true
|
|
authConfig.Username = os.Getenv("MINING_API_USER")
|
|
authConfig.Password = os.Getenv("MINING_API_PASS")
|
|
|
|
if authConfig.Username == "" || authConfig.Password == "" {
|
|
logging.Warn("API auth enabled but credentials not set", logging.Fields{
|
|
"hint": "Set MINING_API_USER and MINING_API_PASS environment variables",
|
|
})
|
|
authConfig.Enabled = false
|
|
}
|
|
}
|
|
|
|
if realm := os.Getenv("MINING_API_REALM"); realm != "" {
|
|
authConfig.Realm = realm
|
|
}
|
|
|
|
return authConfig
|
|
}
|
|
|
|
// 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{}),
|
|
}
|
|
// go digestAuth.cleanupNonces() // clears expired nonces every 5 minutes
|
|
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(requestContext *gin.Context) {
|
|
if !digestAuth.config.Enabled {
|
|
requestContext.Next()
|
|
return
|
|
}
|
|
|
|
authHeader := requestContext.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
digestAuth.sendChallenge(requestContext)
|
|
return
|
|
}
|
|
|
|
// Try digest auth first
|
|
if strings.HasPrefix(authHeader, "Digest ") {
|
|
if digestAuth.validateDigest(requestContext, authHeader) {
|
|
requestContext.Next()
|
|
return
|
|
}
|
|
digestAuth.sendChallenge(requestContext)
|
|
return
|
|
}
|
|
|
|
// Fall back to basic auth
|
|
if strings.HasPrefix(authHeader, "Basic ") {
|
|
if digestAuth.validateBasic(requestContext, authHeader) {
|
|
requestContext.Next()
|
|
return
|
|
}
|
|
}
|
|
|
|
digestAuth.sendChallenge(requestContext)
|
|
}
|
|
}
|
|
|
|
// digestAuth.sendChallenge(c) // writes WWW-Authenticate header and 401 JSON response
|
|
func (digestAuth *DigestAuth) sendChallenge(requestContext *gin.Context) {
|
|
nonce := digestAuth.generateNonce()
|
|
digestAuth.nonces.Store(nonce, time.Now())
|
|
|
|
challenge := `Digest realm="` + digestAuth.config.Realm + `", qop="auth", nonce="` + nonce + `", opaque="` + digestAuth.generateOpaque() + `"`
|
|
|
|
requestContext.Header("WWW-Authenticate", challenge)
|
|
requestContext.AbortWithStatusJSON(http.StatusUnauthorized, APIError{
|
|
Code: "AUTH_REQUIRED",
|
|
Message: "Authentication required",
|
|
Suggestion: "Provide valid credentials using Digest or Basic authentication",
|
|
})
|
|
}
|
|
|
|
// valid := digestAuth.validateDigest(requestContext, requestContext.GetHeader("Authorization"))
|
|
func (digestAuth *DigestAuth) validateDigest(requestContext *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(requestContext.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(requestContext, requestContext.GetHeader("Authorization"))
|
|
func (digestAuth *DigestAuth) validateBasic(requestContext *gin.Context, authHeader string) bool {
|
|
// Gin has built-in basic auth, but we do manual validation for consistency
|
|
username, password, ok := requestContext.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[:])
|
|
}
|