refactor: unify internal api authentication

This commit is contained in:
richjohnrambo
2026-09-21 23:08:15 +08:00
parent 186cfa21c8
commit dc36ce292e
3 changed files with 36 additions and 30 deletions
+15 -21
View File
@@ -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)
+1 -1
View File
@@ -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), "/"))
+20 -8
View File
@@ -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())
}
}