207 lines
5.7 KiB
Go
207 lines
5.7 KiB
Go
// 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[:])
|
||
|
|
}
|