feat: add WithHTTPSign HTTP signature verification middleware
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
67dcc83a37
commit
7b3f99e421
4 changed files with 240 additions and 0 deletions
1
go.mod
1
go.mod
|
|
@ -9,6 +9,7 @@ require (
|
|||
github.com/gin-contrib/authz v1.0.6
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-contrib/gzip v1.2.5
|
||||
github.com/gin-contrib/httpsign v1.0.3
|
||||
github.com/gin-contrib/secure v1.1.2
|
||||
github.com/gin-contrib/sessions v1.0.4
|
||||
github.com/gin-contrib/slog v1.2.0
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -36,6 +36,8 @@ github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQ
|
|||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||
github.com/gin-contrib/httpsign v1.0.3 h1:NpeDQjmUV0qFjGCm/rkXSp3HH0hU7r84q1v+VtTiI5I=
|
||||
github.com/gin-contrib/httpsign v1.0.3/go.mod h1:n4GC7StmHNBhIzWzuW2njKbZMeEWh4tDbmn3bD1ab+k=
|
||||
github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U=
|
||||
github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w=
|
||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||
|
|
|
|||
216
httpsign_test.go
Normal file
216
httpsign_test.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/httpsign"
|
||||
"github.com/gin-contrib/httpsign/crypto"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/go-api"
|
||||
)
|
||||
|
||||
const testSecretKey = "test-secret-key-for-hmac-sha256"
|
||||
|
||||
// testKeyID is the key ID used in HTTP signature tests.
|
||||
var testKeyID = httpsign.KeyID("test-client")
|
||||
|
||||
// newTestSecrets builds a Secrets map with a single HMAC-SHA256 key for testing.
|
||||
func newTestSecrets() httpsign.Secrets {
|
||||
return httpsign.Secrets{
|
||||
testKeyID: &httpsign.Secret{
|
||||
Key: testSecretKey,
|
||||
Algorithm: &crypto.HmacSha256{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// signRequest constructs a valid HTTP Signature Authorization header for the
|
||||
// given request, signing the specified headers with HMAC-SHA256 and the test
|
||||
// secret key. The Date header is set to the current time if not already present.
|
||||
func signRequest(req *http.Request, keyID httpsign.KeyID, secret string, headers []string) {
|
||||
// Ensure a Date header exists.
|
||||
if req.Header.Get("Date") == "" {
|
||||
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
// Build the signing string in the same way the library does:
|
||||
// each header as "header: value", joined by newlines.
|
||||
var parts []string
|
||||
for _, h := range headers {
|
||||
var val string
|
||||
switch h {
|
||||
case "(request-target)":
|
||||
val = fmt.Sprintf("%s %s", strings.ToLower(req.Method), req.URL.RequestURI())
|
||||
case "host":
|
||||
val = req.Host
|
||||
default:
|
||||
val = req.Header.Get(h)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s: %s", h, val))
|
||||
}
|
||||
signingString := strings.Join(parts, "\n")
|
||||
|
||||
// Sign with HMAC-SHA256.
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(signingString))
|
||||
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
// Build the Authorization header.
|
||||
authValue := fmt.Sprintf(
|
||||
"Signature keyId=\"%s\",algorithm=\"hmac-sha256\",headers=\"%s\",signature=\"%s\"",
|
||||
keyID,
|
||||
strings.Join(headers, " "),
|
||||
sig,
|
||||
)
|
||||
req.Header.Set("Authorization", authValue)
|
||||
}
|
||||
|
||||
// ── WithHTTPSign ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestWithHTTPSign_Good_ValidSignatureAccepted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Use only (request-target) and date as required headers, disable
|
||||
// validators to keep the test focused on signature verification.
|
||||
requiredHeaders := []string{"(request-target)", "date"}
|
||||
|
||||
e, _ := api.New(api.WithHTTPSign(
|
||||
newTestSecrets(),
|
||||
httpsign.WithRequiredHeaders(requiredHeaders),
|
||||
httpsign.WithValidator(), // no validators — pure signature check
|
||||
))
|
||||
e.Register(&stubGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
||||
signRequest(req, testKeyID, testSecretKey, requiredHeaders)
|
||||
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for validly signed request, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithHTTPSign_Bad_InvalidSignatureRejected(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
requiredHeaders := []string{"(request-target)", "date"}
|
||||
|
||||
e, _ := api.New(api.WithHTTPSign(
|
||||
newTestSecrets(),
|
||||
httpsign.WithRequiredHeaders(requiredHeaders),
|
||||
httpsign.WithValidator(),
|
||||
))
|
||||
e.Register(&stubGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
||||
|
||||
// Sign with the wrong secret so the signature is invalid.
|
||||
signRequest(req, testKeyID, "wrong-secret-key", requiredHeaders)
|
||||
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for invalid signature, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithHTTPSign_Bad_MissingSignatureRejected(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
requiredHeaders := []string{"(request-target)", "date"}
|
||||
|
||||
e, _ := api.New(api.WithHTTPSign(
|
||||
newTestSecrets(),
|
||||
httpsign.WithRequiredHeaders(requiredHeaders),
|
||||
httpsign.WithValidator(),
|
||||
))
|
||||
e.Register(&stubGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Send a request with no signature at all.
|
||||
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
||||
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
|
||||
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for missing signature, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithHTTPSign_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
requiredHeaders := []string{"(request-target)", "date"}
|
||||
|
||||
e, _ := api.New(
|
||||
api.WithRequestID(),
|
||||
api.WithHTTPSign(
|
||||
newTestSecrets(),
|
||||
httpsign.WithRequiredHeaders(requiredHeaders),
|
||||
httpsign.WithValidator(),
|
||||
),
|
||||
)
|
||||
e.Register(&stubGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
||||
signRequest(req, testKeyID, testSecretKey, requiredHeaders)
|
||||
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify that WithRequestID also ran.
|
||||
if w.Header().Get("X-Request-ID") == "" {
|
||||
t.Fatal("expected X-Request-ID header from WithRequestID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithHTTPSign_Ugly_UnknownKeyIDRejected(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
requiredHeaders := []string{"(request-target)", "date"}
|
||||
|
||||
e, _ := api.New(api.WithHTTPSign(
|
||||
newTestSecrets(),
|
||||
httpsign.WithRequiredHeaders(requiredHeaders),
|
||||
httpsign.WithValidator(),
|
||||
))
|
||||
e.Register(&stubGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
||||
|
||||
// Sign with an unknown key ID that does not exist in the secrets map.
|
||||
unknownKeyID := httpsign.KeyID("unknown-client")
|
||||
signRequest(req, unknownKeyID, testSecretKey, requiredHeaders)
|
||||
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for unknown key ID, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
21
options.go
21
options.go
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/gin-contrib/authz"
|
||||
"github.com/gin-contrib/cors"
|
||||
gingzip "github.com/gin-contrib/gzip"
|
||||
"github.com/gin-contrib/httpsign"
|
||||
"github.com/gin-contrib/secure"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
|
|
@ -232,3 +233,23 @@ func WithAuthz(enforcer *casbin.Enforcer) Option {
|
|||
e.middlewares = append(e.middlewares, authz.NewAuthorizer(enforcer))
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPSign adds HTTP signature verification middleware via
|
||||
// gin-contrib/httpsign. Incoming requests must carry a valid cryptographic
|
||||
// signature in the Authorization or Signature header as defined by the HTTP
|
||||
// Signatures specification (draft-cavage-http-signatures).
|
||||
//
|
||||
// The caller provides a key store mapping key IDs to secrets (each pairing a
|
||||
// shared key with a signing algorithm). Optional httpsign.Option values may
|
||||
// configure required headers or custom validators; sensible defaults apply
|
||||
// when omitted (date, digest, and request-target headers are required; date
|
||||
// and digest validators are enabled).
|
||||
//
|
||||
// Requests with a missing, malformed, or invalid signature are rejected with
|
||||
// 401 Unauthorised or 400 Bad Request.
|
||||
func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
|
||||
return func(e *Engine) {
|
||||
auth := httpsign.NewAuthenticator(secrets, opts...)
|
||||
e.middlewares = append(e.middlewares, auth.Authenticated())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue