217 lines
5.9 KiB
Go
217 lines
5.9 KiB
Go
|
|
// 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)
|
||
|
|
}
|
||
|
|
}
|