// Package auth handles HTTP authentication schemes (Basic, Digest, OAuth 2.0). //go:build linux || freebsd // +build linux freebsd package auth import ( "crypto/md5" "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "net/http" "strings" ) // DigestAuthHandler handles HTTP Digest Authentication type DigestAuthHandler struct { username string password string } // NewDigestAuthHandler creates new handler func NewDigestAuthHandler(username, password string) *DigestAuthHandler { return &DigestAuthHandler{username, password} } // HandleResponse processes a 401 response and returns a modified request func (h *DigestAuthHandler) HandleResponse(req *http.Request, resp *http.Response) (*http.Request, error) { // Parse WWW-Authenticate header authHeader := resp.Header.Get("WWW-Authenticate") if !strings.HasPrefix(authHeader, "Digest ") { return nil, fmt.Errorf("not a digest auth challenge") } // Parse digest parameters params := parseDigestParams(authHeader[7:]) // Validate required parameters requiredParams := []string{"realm", "nonce"} for _, param := range requiredParams { if _, exists := params[param]; !exists { return nil, fmt.Errorf("missing required digest parameter: %s", param) } } // Calculate response hash according to RFC 2617 / RFC 7616 qop := params.get("qop") nonceCount := params.get("nc") cnonce := params.get("cnonce") opaque := params.get("opaque") // HA1 = MD5(username:realm:password) or MD5(username:realm:password):nonce:cnonce ha1 := h.calculateHA1(params["realm"], params["nonce"], cnonce, qop) // HA2 = MD5(method:uri) or MD5(method:uri:response-body) for auth-int. // RFC 7616 §3.4 (and RFC 2617 §3.2.2.3) require uri to be the // request-uri — the path *and* the query string, exactly as it // appears in the request line. Using req.URL.Path strips the // query, which causes digest auth to fail for any URL that carries // a token or other parameter in the query string. ha2 := h.calculateHA2(req.Method, req.URL.RequestURI(), qop) // Response calculation based on qop var response string if qop == "auth" || qop == "auth-int" { response = md5Sum(ha1 + ":" + params["nonce"] + ":" + nonceCount + ":" + cnonce + ":" + qop + ":" + ha2) } else { // No qop (old RFC 2617 style) response = md5Sum(ha1 + ":" + params["nonce"] + ":" + ha2) } // Build Authorization header — see note above about request-uri // vs path. auth := h.buildAuthHeader(params, req.URL.RequestURI(), response, nonceCount, cnonce, qop, opaque) // Clone request and add header newReq := req.Clone(req.Context()) newReq.Header.Set("Authorization", auth) return newReq, nil } func (h *DigestAuthHandler) calculateHA1(realm, nonce, cnonce, qop string) string { a1 := h.username + ":" + realm + ":" + h.password ha1 := md5Sum(a1) // If qop is specified, we need to include nonce and cnonce if qop == "auth" || qop == "auth-int" { ha1 = md5Sum(ha1 + ":" + nonce + ":" + cnonce) } return ha1 } func (h *DigestAuthHandler) calculateHA2(method, uri, qop string) string { a2 := method + ":" + uri // For auth-int, we would need to include body hash, but that's complex // For now, we just support auth and no-qop modes return md5Sum(a2) } func (h *DigestAuthHandler) buildAuthHeader(params map[string]string, uri, response, nc, cnonce, qop, opaque string) string { parts := []string{ fmt.Sprintf(`username="%s"`, h.username), fmt.Sprintf(`realm="%s"`, params["realm"]), fmt.Sprintf(`nonce="%s"`, params["nonce"]), fmt.Sprintf(`uri="%s"`, uri), fmt.Sprintf(`response="%s"`, response), } if qop != "" { parts = append(parts, fmt.Sprintf(`qop=%s`, qop), fmt.Sprintf(`nc=%s`, nc), fmt.Sprintf(`cnonce="%s"`, cnonce), ) } if opaque != "" { parts = append(parts, fmt.Sprintf(`opaque="%s"`, opaque)) } return "Digest " + strings.Join(parts, ", ") } // GenerateCnonce generates a cryptographically random client nonce func GenerateCnonce() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { // Fallback should never happen on a functioning system // but we still produce a value rather than crash for i := range b { b[i] = byte(i) } } return hex.EncodeToString(b) } // parseDigestParams parses the WWW-Authenticate header parameters func parseDigestParams(header string) digestParams { params := make(digestParams) // Handle quoted and unquoted values currentKey := "" currentVal := "" inQuotes := false hasSeenEquals := false for i := 0; i < len(header); i++ { char := header[i] switch { case char == '=' && !inQuotes: currentKey = strings.TrimSpace(currentKey) currentVal = "" hasSeenEquals = true case char == '"' && (i == 0 || header[i-1] != '\\'): inQuotes = !inQuotes case char == ',' && !inQuotes: if currentKey != "" { params[currentKey] = strings.Trim(strings.TrimSpace(currentVal), `"`) } currentKey = "" currentVal = "" hasSeenEquals = false case (char == ' ' || char == '\t') && !inQuotes && ((!hasSeenEquals && currentKey == "") || (hasSeenEquals && currentVal == "")): // Skip leading whitespace before a key or after '=' default: if !hasSeenEquals { currentKey += string(char) } else { currentVal += string(char) } } } // Don't forget the last parameter if currentKey != "" { params[currentKey] = strings.Trim(strings.TrimSpace(currentVal), `"`) } return params } // digestParams is a map of digest authentication parameters type digestParams map[string]string func (p digestParams) get(key string) string { if val, ok := p[key]; ok { return val } return "" } func md5Sum(s string) string { h := md5.Sum([]byte(s)) return hex.EncodeToString(h[:]) } // SHA256 sum for future use (RFC 7616 supports SHA-256) func sha256Sum(s string) string { h := sha256.Sum256([]byte(s)) return hex.EncodeToString(h[:]) }