refactor: unify internal api authentication
This commit is contained in:
+15
-21
@@ -25,8 +25,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
internalCapabilityAdminQuery = "admin_query"
|
||||
internalCapabilityCultivate = "cultivate"
|
||||
internalSharedCredentialService = "hotgo-admin"
|
||||
|
||||
internalHeaderService = "X-Service-Name"
|
||||
internalHeaderTimestamp = "X-Timestamp"
|
||||
@@ -85,7 +84,7 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
if !authorizeInternalAPI(wrt, req, internalCapabilityAdminQuery) {
|
||||
if !authorizeInternalAPI(wrt, req) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -363,7 +362,7 @@ func serveInternalLoginLockoutRelease(wrt http.ResponseWriter, req *http.Request
|
||||
writeIMSuccess(wrt, req, map[string]any{"account_hash": accountHash, "status": "released"})
|
||||
}
|
||||
|
||||
func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability string) bool {
|
||||
func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request) bool {
|
||||
if !internalAPI.config.Enabled {
|
||||
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
|
||||
return false
|
||||
@@ -372,15 +371,11 @@ func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability
|
||||
timestampRaw := strings.TrimSpace(req.Header.Get(internalHeaderTimestamp))
|
||||
nonce := strings.TrimSpace(req.Header.Get(internalHeaderNonce))
|
||||
signature := strings.TrimSpace(req.Header.Get(internalHeaderSignature))
|
||||
service, ok := internalAPI.config.Services[serviceName]
|
||||
if !ok || service.Secret == "" || timestampRaw == "" || nonce == "" || signature == "" {
|
||||
secret := internalSharedSecret()
|
||||
if serviceName == "" || secret == "" || timestampRaw == "" || nonce == "" || signature == "" {
|
||||
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "internal signature required", nil)
|
||||
return false
|
||||
}
|
||||
if !internalServiceAllows(service, capability) {
|
||||
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
||||
return false
|
||||
}
|
||||
timestamp, err := strconv.ParseInt(timestampRaw, 10, 64)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal timestamp", nil)
|
||||
@@ -403,7 +398,7 @@ func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability
|
||||
timestampRaw,
|
||||
nonce,
|
||||
}, "\n")
|
||||
if !verifyInternalSignature(service.Secret, signingText, signature) {
|
||||
if !verifyInternalSignature(secret, signingText, signature) {
|
||||
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal signature", nil)
|
||||
return false
|
||||
}
|
||||
@@ -415,6 +410,15 @@ func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability
|
||||
return true
|
||||
}
|
||||
|
||||
// internalSharedSecret returns the single credential used by all trusted
|
||||
// backend callers. Service names remain request labels for audit and nonce
|
||||
// isolation only; they no longer select separate credentials or capabilities.
|
||||
// Reuse the existing hotgo-admin credential so current deployments require no
|
||||
// additional secret or configuration changes.
|
||||
func internalSharedSecret() string {
|
||||
return strings.TrimSpace(internalAPI.config.Services[internalSharedCredentialService].Secret)
|
||||
}
|
||||
|
||||
func splitInternalPath(tail string) []string {
|
||||
if tail == "" {
|
||||
return nil
|
||||
@@ -765,16 +769,6 @@ func parseInternalUid(raw string) types.Uid {
|
||||
return store.EncodeUid(id)
|
||||
}
|
||||
|
||||
func internalServiceAllows(service internalAPIServiceConfig, capability string) bool {
|
||||
for _, allowed := range service.Allow {
|
||||
allowed = strings.TrimSpace(allowed)
|
||||
if allowed == "*" || allowed == capability {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hashInternalRequestBody(req *http.Request) (string, error) {
|
||||
if req.Body == nil {
|
||||
sum := sha256.Sum256(nil)
|
||||
|
||||
@@ -51,7 +51,7 @@ func serveInternalCultivate(w http.ResponseWriter, r *http.Request, prefix strin
|
||||
writeIMError(w, r, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
if !authorizeInternalAPI(w, r, internalCapabilityCultivate) {
|
||||
if !authorizeInternalAPI(w, r) {
|
||||
return
|
||||
}
|
||||
parts := splitInternalPath(strings.Trim(strings.TrimPrefix(r.URL.Path, prefix), "/"))
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInternalCultivateRequiresSignedServiceRequest(t *testing.T) {
|
||||
previous := internalAPI
|
||||
defer func() { internalAPI = previous }()
|
||||
internalAPI = internalAPIRuntime{config: internalAPIConfig{Enabled: true, Services: map[string]internalAPIServiceConfig{"cultivate-server": {Secret: "secret", Allow: []string{internalCapabilityCultivate}}}}}
|
||||
internalAPI = internalAPIRuntime{config: internalAPIConfig{Enabled: true, Services: map[string]internalAPIServiceConfig{"hotgo-admin": {Secret: "secret"}}}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/cultivate/tenants/default/users/batch", strings.NewReader(`{"user_ids":[1]}`))
|
||||
rec := httptest.NewRecorder()
|
||||
serveInternalCultivate(rec, req, "/internal/cultivate/")
|
||||
@@ -19,18 +24,25 @@ func TestInternalCultivateRequiresSignedServiceRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalCultivateRejectsWrongCapability(t *testing.T) {
|
||||
func TestInternalCultivateUsesSharedInternalCredential(t *testing.T) {
|
||||
previous := internalAPI
|
||||
defer func() { internalAPI = previous }()
|
||||
internalAPI = internalAPIRuntime{config: internalAPIConfig{Enabled: true, Services: map[string]internalAPIServiceConfig{"cultivate-server": {Secret: "secret", Allow: []string{internalCapabilityAdminQuery}}}}}
|
||||
internalAPI = internalAPIRuntime{config: internalAPIConfig{Enabled: true, TimeWindowSeconds: 300, Services: map[string]internalAPIServiceConfig{
|
||||
"hotgo-admin": {Secret: "shared-secret", Allow: []string{"admin_query"}},
|
||||
"cultivate-server": {Secret: "ignored-separate-secret", Allow: []string{"cultivate"}},
|
||||
}}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/cultivate/tenants/default/users/batch", strings.NewReader(`{"user_ids":[1]}`))
|
||||
req.Header.Set(internalHeaderService, "cultivate-server")
|
||||
req.Header.Set(internalHeaderTimestamp, "1")
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
req.Header.Set(internalHeaderTimestamp, timestamp)
|
||||
req.Header.Set(internalHeaderNonce, "nonce")
|
||||
req.Header.Set(internalHeaderSignature, "signature")
|
||||
bodyHash := sha256.Sum256([]byte(`{"user_ids":[1]}`))
|
||||
signingText := strings.Join([]string{req.Method, req.URL.RequestURI(), hex.EncodeToString(bodyHash[:]), timestamp, "nonce"}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte("shared-secret"))
|
||||
_, _ = mac.Write([]byte(signingText))
|
||||
req.Header.Set(internalHeaderSignature, hex.EncodeToString(mac.Sum(nil)))
|
||||
rec := httptest.NewRecorder()
|
||||
serveInternalCultivate(rec, req, "/internal/cultivate/")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if !authorizeInternalAPI(rec, req) {
|
||||
t.Fatalf("shared internal credential was rejected: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user