Mining/pkg/mining/auth.go
snider c2ff474386 feat: Add API authentication and comprehensive code review fixes
Security:
- Add HTTP Basic/Digest authentication middleware (enable via MINING_API_AUTH env)
- Fix WebSocket origin check with proper URL parsing
- Add max limit (10000) to remote log lines request
- Improve CLI args validation with stricter patterns

Networking:
- Fix WebSocket double-close with sync.Once in PeerConnection
- Add 10s dial timeout for WebSocket connections
- Reset write deadline after failed sends
- Fix handler race in Transport.OnMessage with RWMutex
- Make EventHub.Stop() idempotent, buffer channels to prevent goroutine leaks

Code Simplification:
- Extract AtomicWriteFile helper to reduce duplication across 4 files
- Remove redundant MinerTypeRegistry, use MinerFactory instead
- Register simulated miner in MinerFactory
- Remove dead portToString() code from manager.go

Documentation:
- Add Advanced API Authentication section to FUTURE_IDEAS.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 14:07:26 +00:00

248 lines
6.2 KiB
Go

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
}
// NewDigestAuth creates a new digest auth middleware
func NewDigestAuth(config AuthConfig) *DigestAuth {
da := &DigestAuth{config: config}
// Start nonce cleanup goroutine
go da.cleanupNonces()
return da
}
// 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
if params["username"] != da.config.Username {
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)
rand.Read(b)
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() {
ticker := time.NewTicker(da.config.NonceExpiry)
defer ticker.Stop()
for range 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[:])
}