mirror of
https://github.com/yusing/godoxy.git
synced 2026-03-21 17:10:14 +01:00
Implement Signed Double Submit Cookie pattern to prevent CSRF attacks. Adds CSRF token generation, validation, and middleware for API endpoints. Safe methods (GET/HEAD/OPTIONS) automatically receive CSRF cookies, while unsafe methods require X-CSRF-Token header matching the cookie value with valid HMAC signature. Includes same-origin exemption for login/callback endpoints to support browser-based authentication flows.
85 lines
2.1 KiB
Go
85 lines
2.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/yusing/godoxy/internal/common"
|
|
"golang.org/x/crypto/hkdf"
|
|
)
|
|
|
|
const (
|
|
CSRFCookieName = "godoxy_csrf"
|
|
CSRFHKDFSalt = "godoxy-csrf"
|
|
CSRFHeaderName = "X-CSRF-Token"
|
|
csrfTokenLength = 32
|
|
)
|
|
|
|
// csrfSecret is derived from API_JWT_SECRET via HKDF for cryptographic
|
|
// separation from JWT signing. Falls back to an ephemeral random key
|
|
// for OIDC-only setups where no JWT secret is configured.
|
|
var csrfSecret = func() []byte {
|
|
if common.APIJWTSecret != nil {
|
|
return hkdf.Extract(sha256.New, common.APIJWTSecret, []byte(CSRFHKDFSalt))
|
|
}
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic("failed to generate CSRF secret: " + err.Error())
|
|
}
|
|
return b
|
|
}()
|
|
|
|
func GenerateCSRFToken() (string, error) {
|
|
nonce := make([]byte, csrfTokenLength)
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return "", err
|
|
}
|
|
nonceHex := hex.EncodeToString(nonce)
|
|
return nonceHex + "." + csrfSign(nonceHex), nil
|
|
}
|
|
|
|
// ValidateCSRFToken checks the HMAC signature embedded in the token.
|
|
// This prevents subdomain cookie-injection attacks where an attacker
|
|
// sets a forged CSRF cookie — they cannot produce a valid signature
|
|
// without the ephemeral secret.
|
|
func ValidateCSRFToken(token string) bool {
|
|
nonce, sig, ok := strings.Cut(token, ".")
|
|
if !ok || len(nonce) != csrfTokenLength*2 {
|
|
return false
|
|
}
|
|
return hmac.Equal([]byte(sig), []byte(csrfSign(nonce)))
|
|
}
|
|
|
|
func csrfSign(nonce string) string {
|
|
mac := hmac.New(sha256.New, csrfSecret)
|
|
mac.Write([]byte(nonce))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func SetCSRFCookie(w http.ResponseWriter, r *http.Request, token string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CSRFCookieName,
|
|
Value: token,
|
|
HttpOnly: false,
|
|
Secure: common.APIJWTSecure,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Path: "/",
|
|
})
|
|
}
|
|
|
|
func ClearCSRFCookie(w http.ResponseWriter, r *http.Request) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CSRFCookieName,
|
|
Value: "",
|
|
MaxAge: -1,
|
|
HttpOnly: false,
|
|
Secure: common.APIJWTSecure,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Path: "/",
|
|
})
|
|
}
|