refactor: unify internal api authentication
This commit is contained in:
+15
-21
@@ -25,8 +25,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
internalCapabilityAdminQuery = "admin_query"
|
internalSharedCredentialService = "hotgo-admin"
|
||||||
internalCapabilityCultivate = "cultivate"
|
|
||||||
|
|
||||||
internalHeaderService = "X-Service-Name"
|
internalHeaderService = "X-Service-Name"
|
||||||
internalHeaderTimestamp = "X-Timestamp"
|
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)
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !authorizeInternalAPI(wrt, req, internalCapabilityAdminQuery) {
|
if !authorizeInternalAPI(wrt, req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +362,7 @@ func serveInternalLoginLockoutRelease(wrt http.ResponseWriter, req *http.Request
|
|||||||
writeIMSuccess(wrt, req, map[string]any{"account_hash": accountHash, "status": "released"})
|
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 {
|
if !internalAPI.config.Enabled {
|
||||||
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
|
||||||
return false
|
return false
|
||||||
@@ -372,15 +371,11 @@ func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability
|
|||||||
timestampRaw := strings.TrimSpace(req.Header.Get(internalHeaderTimestamp))
|
timestampRaw := strings.TrimSpace(req.Header.Get(internalHeaderTimestamp))
|
||||||
nonce := strings.TrimSpace(req.Header.Get(internalHeaderNonce))
|
nonce := strings.TrimSpace(req.Header.Get(internalHeaderNonce))
|
||||||
signature := strings.TrimSpace(req.Header.Get(internalHeaderSignature))
|
signature := strings.TrimSpace(req.Header.Get(internalHeaderSignature))
|
||||||
service, ok := internalAPI.config.Services[serviceName]
|
secret := internalSharedSecret()
|
||||||
if !ok || service.Secret == "" || timestampRaw == "" || nonce == "" || signature == "" {
|
if serviceName == "" || secret == "" || timestampRaw == "" || nonce == "" || signature == "" {
|
||||||
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "internal signature required", nil)
|
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "internal signature required", nil)
|
||||||
return false
|
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)
|
timestamp, err := strconv.ParseInt(timestampRaw, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal timestamp", 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,
|
timestampRaw,
|
||||||
nonce,
|
nonce,
|
||||||
}, "\n")
|
}, "\n")
|
||||||
if !verifyInternalSignature(service.Secret, signingText, signature) {
|
if !verifyInternalSignature(secret, signingText, signature) {
|
||||||
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal signature", nil)
|
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal signature", nil)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -415,6 +410,15 @@ func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability
|
|||||||
return true
|
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 {
|
func splitInternalPath(tail string) []string {
|
||||||
if tail == "" {
|
if tail == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -765,16 +769,6 @@ func parseInternalUid(raw string) types.Uid {
|
|||||||
return store.EncodeUid(id)
|
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) {
|
func hashInternalRequestBody(req *http.Request) (string, error) {
|
||||||
if req.Body == nil {
|
if req.Body == nil {
|
||||||
sum := sha256.Sum256(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)
|
writeIMError(w, r, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !authorizeInternalAPI(w, r, internalCapabilityCultivate) {
|
if !authorizeInternalAPI(w, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
parts := splitInternalPath(strings.Trim(strings.TrimPrefix(r.URL.Path, prefix), "/"))
|
parts := splitInternalPath(strings.Trim(strings.TrimPrefix(r.URL.Path, prefix), "/"))
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestInternalCultivateRequiresSignedServiceRequest(t *testing.T) {
|
func TestInternalCultivateRequiresSignedServiceRequest(t *testing.T) {
|
||||||
previous := internalAPI
|
previous := internalAPI
|
||||||
defer func() { internalAPI = previous }()
|
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]}`))
|
req := httptest.NewRequest(http.MethodPost, "/internal/cultivate/tenants/default/users/batch", strings.NewReader(`{"user_ids":[1]}`))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
serveInternalCultivate(rec, req, "/internal/cultivate/")
|
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
|
previous := internalAPI
|
||||||
defer func() { internalAPI = previous }()
|
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 := httptest.NewRequest(http.MethodPost, "/internal/cultivate/tenants/default/users/batch", strings.NewReader(`{"user_ids":[1]}`))
|
||||||
req.Header.Set(internalHeaderService, "cultivate-server")
|
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(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()
|
rec := httptest.NewRecorder()
|
||||||
serveInternalCultivate(rec, req, "/internal/cultivate/")
|
if !authorizeInternalAPI(rec, req) {
|
||||||
if rec.Code != http.StatusForbidden {
|
t.Fatalf("shared internal credential was rejected: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user