feat: add shared backend authorization seams

This commit is contained in:
zn-admin committed 2026-08-13 16:03:20 +08:00
1 parent d0207fcebe
commit 48dd5d07c8
9 files changed
+1086

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
package httpapi
import (
"errors"
"fmt"
"net/http"
"time"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
)
// PlatformRequirement is the route-level authorization policy evaluated after
// Identity has refreshed all account and organization claims from persistence.
type PlatformRequirement string
const (
PlatformApp PlatformRequirement = "app"
PlatformAdmin PlatformRequirement = "admin"
PlatformSuperAdmin PlatformRequirement = "super_admin"
)
// PlatformAuthErrorKind is deliberately transport-oriented. Domain handlers
// can map it to the current stable status without learning Cookie/parser detail.
type PlatformAuthErrorKind string
const (
PlatformUnauthenticated PlatformAuthErrorKind = "unauthenticated"
PlatformForbidden PlatformAuthErrorKind = "forbidden"
PlatformConfigurationError PlatformAuthErrorKind = "configuration_error"
)
// PlatformAuthError represents only expected authentication and RBAC denials.
// Resolver/database failures remain ordinary errors and must become generic 500s.
type PlatformAuthError struct {
Kind PlatformAuthErrorKind
Status int
Message string
}
func (err *PlatformAuthError) Error() string {
if err.Message != "" {
return err.Message
}
return string(err.Kind)
}
type PlatformAuthorizer struct {
state AuthState
resolver SessionResolver
now func() time.Time
}
// NewPlatformAuthorizer creates the single HTTP-side platform authentication
// seam shared by protected route Modules.
func NewPlatformAuthorizer(state AuthState, resolver SessionResolver) (*PlatformAuthorizer, error) {
if state.Configured && resolver == nil {
return nil, fmt.Errorf("platform authorization: configured authentication requires a session resolver")
}
return &PlatformAuthorizer{state: state, resolver: resolver, now: time.Now}, nil
}
// Authorize returns either a database-refreshed platform Session, the exact
// local-development fallback when authentication is optional, an expected
// typed denial, or an unclassified infrastructure error.
func (authorizer *PlatformAuthorizer) Authorize(r *http.Request, requirement PlatformRequirement) (identity.Session, error) {
if authorizer == nil {
return identity.Session{}, fmt.Errorf("platform authorizer is not configured")
}
if !validPlatformRequirement(requirement) {
return identity.Session{}, fmt.Errorf("unsupported platform authorization requirement %q", requirement)
}
if authorizer.state.Configured {
cookieValue, found := readSessionCookie(r)
if found && len(cookieValue) <= identity.CookieMaxValueLength {
session, err := authorizer.resolver.Resolve(r.Context(), cookieValue)
if err == nil {
return authorizePlatformRole(session, requirement)
}
if !errors.Is(err, identity.ErrUnauthenticated) {
return identity.Session{}, err
}
}
}
if !authorizer.state.Required {
return authorizePlatformRole(authorizer.localSession(), requirement)
}
if !authorizer.state.Configured {
return identity.Session{}, &PlatformAuthError{
Kind: PlatformConfigurationError, Status: http.StatusServiceUnavailable,
Message: "认证配置不完整。",
}
}
return identity.Session{}, &PlatformAuthError{
Kind: PlatformUnauthenticated, Status: http.StatusUnauthorized,
Message: "请先登录。",
}
}
func authorizePlatformRole(session identity.Session, requirement PlatformRequirement) (identity.Session, error) {
allowed := requirement == PlatformApp
if requirement == PlatformAdmin {
allowed = session.AuthMode == identity.AuthModeAdmin &&
(session.User.Role == "organization_admin" || session.User.Role == "super_admin")
}
if requirement == PlatformSuperAdmin {
allowed = session.User.Role == "super_admin"
}
if allowed {
return session, nil
}
return identity.Session{}, &PlatformAuthError{
Kind: PlatformForbidden, Status: http.StatusForbidden,
Message: "需要管理员权限。",
}
}
func validPlatformRequirement(requirement PlatformRequirement) bool {
return requirement == PlatformApp || requirement == PlatformAdmin || requirement == PlatformSuperAdmin
}
func (authorizer *PlatformAuthorizer) localSession() identity.Session {
now := authorizer.now()
return identity.Session{
Version: 1,
AuthMode: identity.AuthModeAdmin,
IssuedAt: now.Unix(),
ExpiresAt: now.Add(24 * time.Hour).Unix(),
User: identity.User{
ID: "demo-merchant",
Subject: "demo-merchant",
Username: "13800000000",
Phone: "13800000000",
DisplayName: "智念用户",
ClientID: "local-dev",
OrganizationID: "org-demo",
OrganizationName: "演示组织",
Role: "super_admin",
Status: "active",
Authorities: []string{"zhinian_admin"},
Scope: []string{},
},
}
}
@@ -0,0 +1,165 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
)
func TestPlatformAuthorizerMatchesSharedContract(t *testing.T) {
contract := loadPlatformHTTPAuthContract(t)
for _, contractCase := range contract.Cases {
t.Run(contractCase.Name, func(t *testing.T) {
resolver := &platformSessionResolverStub{outcome: contractCase.ResolverOutcome}
if contractCase.Session != nil {
resolver.session = fixturePlatformSession(*contractCase.Session)
}
authorizer, err := NewPlatformAuthorizer(AuthState(contractCase.State), resolver)
if err != nil {
t.Fatalf("NewPlatformAuthorizer: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
if contractCase.ResolverOutcome != "not_called" {
request.AddCookie(&http.Cookie{Name: contract.CookieName, Value: "signed-cookie"})
}
session, err := authorizer.Authorize(request, PlatformRequirement(contractCase.Requirement))
assertPlatformAuthOutcome(t, contractCase, session, err)
wantCalls := 0
if contractCase.ResolverOutcome != "not_called" {
wantCalls = 1
}
if resolver.calls != wantCalls {
t.Fatalf("resolver calls = %d, want %d", resolver.calls, wantCalls)
}
})
}
}
func TestPlatformAuthorizerUsesSharedChunkReader(t *testing.T) {
resolver := &platformSessionResolverStub{outcome: "authenticated", session: fixturePlatformSession(platformHTTPFixtureSession{AuthMode: "user", Role: "user"})}
authorizer, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, resolver)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "first"})
request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "last"})
request.AddCookie(&http.Cookie{Name: identity.SessionCookieName + ".1", Value: "tail"})
if _, err := authorizer.Authorize(request, PlatformApp); err != nil {
t.Fatalf("Authorize: %v", err)
}
if resolver.value != "lasttail" {
t.Fatalf("resolver value = %q, want lasttail", resolver.value)
}
}
type platformSessionResolverStub struct {
outcome string
session identity.Session
calls int
value string
}
func (stub *platformSessionResolverStub) Resolve(_ context.Context, value string) (identity.Session, error) {
stub.calls++
stub.value = value
switch stub.outcome {
case "authenticated":
return stub.session, nil
case "unauthenticated":
return identity.Session{}, identity.ErrUnauthenticated
case "infrastructure_error":
return identity.Session{}, errors.New("database secret must not leak")
default:
return identity.Session{}, errors.New("unexpected resolver call")
}
}
type platformHTTPAuthContract struct {
Version int `json:"version"`
CookieName string `json:"cookieName"`
Cases []platformHTTPAuthCase `json:"cases"`
}
type platformHTTPAuthCase struct {
Name string `json:"name"`
State AuthState `json:"state"`
Requirement string `json:"requirement"`
ResolverOutcome string `json:"resolverOutcome"`
Session *platformHTTPFixtureSession `json:"session"`
Expected struct {
Outcome string `json:"outcome"`
Status int `json:"status"`
Role string `json:"role"`
AuthMode string `json:"authMode"`
} `json:"expected"`
}
type platformHTTPFixtureSession struct {
AuthMode string `json:"authMode"`
Role string `json:"role"`
}
func loadPlatformHTTPAuthContract(t *testing.T) platformHTTPAuthContract {
t.Helper()
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate platform_auth_test.go")
}
raw, err := os.ReadFile(filepath.Join(filepath.Dir(filename), "..", "..", "..", "contracts", "auth", "platform-http-auth-v1.json"))
if err != nil {
t.Fatal(err)
}
var contract platformHTTPAuthContract
if err := json.Unmarshal(raw, &contract); err != nil {
t.Fatal(err)
}
if contract.Version != 1 || contract.CookieName != identity.SessionCookieName {
t.Fatalf("invalid contract header: %+v", contract)
}
return contract
}
func fixturePlatformSession(input platformHTTPFixtureSession) identity.Session {
return identity.Session{
Version: 1,
AuthMode: identity.AuthMode(input.AuthMode),
User: identity.User{
ID: "fixture-user", Subject: "fixture-user", DisplayName: "Fixture User",
ClientID: "platform", Role: input.Role, Status: "active",
},
}
}
func assertPlatformAuthOutcome(t *testing.T, contractCase platformHTTPAuthCase, session identity.Session, err error) {
t.Helper()
switch contractCase.Expected.Outcome {
case "authenticated":
if err != nil {
t.Fatalf("Authorize error = %v", err)
}
if session.User.Role != contractCase.Expected.Role || string(session.AuthMode) != contractCase.Expected.AuthMode {
t.Fatalf("session role/mode = %q/%q, want %q/%q", session.User.Role, session.AuthMode, contractCase.Expected.Role, contractCase.Expected.AuthMode)
}
case "infrastructure_error":
var authErr *PlatformAuthError
if err == nil || errors.As(err, &authErr) {
t.Fatalf("error = %v, want non-auth infrastructure error", err)
}
default:
var authErr *PlatformAuthError
if !errors.As(err, &authErr) || authErr.Status != contractCase.Expected.Status || string(authErr.Kind) != contractCase.Expected.Outcome {
t.Fatalf("error = %#v, want %s/%d", err, contractCase.Expected.Outcome, contractCase.Expected.Status)
}
}
}
+162
View File
@@ -0,0 +1,162 @@
// Package publicapi authenticates public API clients and internal workers.
// Runtime configuration is parsed once and injected into an Authenticator;
// this package deliberately does not read process environment variables.
package publicapi
import (
"crypto/subtle"
"net/http"
"strings"
)
const ownerPartLimit = 96
type PublicClient struct {
ID string
Key string
}
type Config struct {
APIKeys string
InternalWorkerToken string
Production bool
}
type AuthError struct {
Status int
Message string
}
func (e *AuthError) Error() string {
return e.Message
}
type Authenticator struct {
clients []PublicClient
internalWorkerToken string
production bool
}
func NewAuthenticator(config Config) *Authenticator {
return &Authenticator{
clients: ParseClients(config.APIKeys),
internalWorkerToken: strings.TrimSpace(config.InternalWorkerToken),
production: config.Production,
}
}
func ParseClients(configured string) []PublicClient {
entries := strings.FieldsFunc(configured, func(character rune) bool {
return character == ',' || character == '\n'
})
clients := make([]PublicClient, 0, len(entries))
for _, rawEntry := range entries {
entry := strings.TrimSpace(rawEntry)
if entry == "" {
continue
}
id := "default"
key := entry
if separator := strings.IndexByte(entry, ':'); separator >= 0 {
id = strings.TrimSpace(entry[:separator])
key = strings.TrimSpace(entry[separator+1:])
}
if id == "" || key == "" {
continue
}
clients = append(clients, PublicClient{ID: id, Key: key})
}
return clients
}
func (a *Authenticator) Authenticate(request *http.Request) (PublicClient, string, error) {
presented := publicCredential(request)
if presented == "" {
return PublicClient{}, "", &AuthError{Status: http.StatusUnauthorized, Message: "Missing API key."}
}
for _, client := range a.clients {
if secureEqual(client.Key, presented) {
return client, OwnerID(client.ID), nil
}
}
return PublicClient{}, "", &AuthError{Status: http.StatusUnauthorized, Message: "Invalid API key."}
}
func (a *Authenticator) AssertInternalWorker(request *http.Request) error {
if a.internalWorkerToken == "" && !a.production {
return nil
}
if a.internalWorkerToken == "" {
return &AuthError{Status: http.StatusInternalServerError, Message: "Worker token is not configured."}
}
presented := request.Header.Get("x-zhinian-worker-token")
if presented == "" {
presented = bearerToken(request)
}
if presented == "" || !secureEqual(a.internalWorkerToken, presented) {
return &AuthError{Status: http.StatusUnauthorized, Message: "Invalid worker token."}
}
return nil
}
func OwnerID(id string) string {
part := sanitizeOwnerPart(id)
if part == "" {
part = "unknown"
}
return "api:" + part
}
func publicCredential(request *http.Request) string {
if token := bearerToken(request); token != "" {
return token
}
return request.Header.Get("x-zhinian-api-key")
}
func bearerToken(request *http.Request) string {
authorization := request.Header.Get("authorization")
separator := strings.IndexAny(authorization, " \t\r\n\v\f")
if separator <= 0 || !strings.EqualFold(authorization[:separator], "Bearer") {
return ""
}
if strings.TrimLeft(authorization[separator:], " \t\r\n\v\f") == authorization[separator:] {
return ""
}
return strings.TrimSpace(authorization[separator:])
}
func secureEqual(expected, presented string) bool {
if len(expected) != len(presented) {
return false
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(presented)) == 1
}
func sanitizeOwnerPart(value string) string {
part := make([]byte, 0, min(len(value), ownerPartLimit))
invalidRun := false
for _, character := range value {
if isOwnerCharacter(character) {
invalidRun = false
if len(part) < ownerPartLimit {
part = append(part, byte(character))
}
continue
}
if !invalidRun && len(part) < ownerPartLimit {
part = append(part, '_')
}
invalidRun = true
}
return string(part)
}
func isOwnerCharacter(character rune) bool {
return character >= 'A' && character <= 'Z' ||
character >= 'a' && character <= 'z' ||
character >= '0' && character <= '9' ||
strings.ContainsRune("_.:@-", character)
}
+171
View File
@@ -0,0 +1,171 @@
package publicapi_test
import (
"encoding/json"
"net/http/httptest"
"os"
"testing"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi"
)
type authErrorFixture struct {
Status int `json:"status"`
Message string `json:"message"`
}
type authFixture struct {
Version int `json:"version"`
APIKeys string `json:"apiKeys"`
Clients []struct {
ID string `json:"id"`
Key string `json:"key"`
} `json:"clients"`
AuthenticationCases []struct {
Name string `json:"name"`
Headers map[string]string `json:"headers"`
Expected *struct {
Client struct {
ID string `json:"id"`
Key string `json:"key"`
} `json:"client"`
Owner string `json:"owner"`
} `json:"expected"`
Error *authErrorFixture `json:"error"`
} `json:"authenticationCases"`
OwnerCases []struct {
ID string `json:"id"`
Owner string `json:"owner"`
MaxPartLength int `json:"maxPartLength"`
} `json:"ownerCases"`
WorkerCases []struct {
Name string `json:"name"`
Production bool `json:"production"`
ConfiguredToken string `json:"configuredToken"`
Headers map[string]string `json:"headers"`
Allowed bool `json:"allowed"`
Error *authErrorFixture `json:"error"`
} `json:"workerCases"`
}
func TestParseClientsMatchesSharedContract(t *testing.T) {
fixture := loadAuthFixture(t)
clients := publicapi.ParseClients(fixture.APIKeys)
if len(clients) != len(fixture.Clients) {
t.Fatalf("len(ParseClients()) = %d, want %d", len(clients), len(fixture.Clients))
}
for index, want := range fixture.Clients {
if clients[index] != (publicapi.PublicClient{ID: want.ID, Key: want.Key}) {
t.Fatalf("client[%d] = %+v, want %+v", index, clients[index], want)
}
}
}
func TestAuthenticateMatchesSharedContract(t *testing.T) {
fixture := loadAuthFixture(t)
authenticator := publicapi.NewAuthenticator(publicapi.Config{APIKeys: fixture.APIKeys})
for _, testCase := range fixture.AuthenticationCases {
t.Run(testCase.Name, func(t *testing.T) {
request := httptest.NewRequest("GET", "/api/v1/generations", nil)
for name, value := range testCase.Headers {
request.Header.Set(name, value)
}
client, owner, err := authenticator.Authenticate(request)
if testCase.Error != nil {
assertAuthError(t, err, *testCase.Error)
return
}
if err != nil {
t.Fatalf("Authenticate() error = %v", err)
}
wantClient := publicapi.PublicClient{ID: testCase.Expected.Client.ID, Key: testCase.Expected.Client.Key}
if client != wantClient || owner != testCase.Expected.Owner {
t.Fatalf("Authenticate() = (%+v, %q), want (%+v, %q)", client, owner, wantClient, testCase.Expected.Owner)
}
})
}
}
func TestOwnerIDMatchesSharedContract(t *testing.T) {
fixture := loadAuthFixture(t)
for _, testCase := range fixture.OwnerCases {
t.Run(testCase.ID, func(t *testing.T) {
owner := publicapi.OwnerID(testCase.ID)
if owner != testCase.Owner {
t.Fatalf("OwnerID(%q) = %q, want %q", testCase.ID, owner, testCase.Owner)
}
if testCase.MaxPartLength > 0 && len(owner)-len("api:") != testCase.MaxPartLength {
t.Fatalf("owner part length = %d, want %d", len(owner)-len("api:"), testCase.MaxPartLength)
}
})
}
}
func TestAssertInternalWorkerMatchesSharedContract(t *testing.T) {
fixture := loadAuthFixture(t)
for _, testCase := range fixture.WorkerCases {
t.Run(testCase.Name, func(t *testing.T) {
authenticator := publicapi.NewAuthenticator(publicapi.Config{
InternalWorkerToken: testCase.ConfiguredToken,
Production: testCase.Production,
})
request := httptest.NewRequest("POST", "/api/internal/worker/tick", nil)
for name, value := range testCase.Headers {
request.Header.Set(name, value)
}
err := authenticator.AssertInternalWorker(request)
if testCase.Error != nil {
assertAuthError(t, err, *testCase.Error)
return
}
if err != nil {
t.Fatalf("AssertInternalWorker() error = %v", err)
}
})
}
}
func TestAuthenticatorUsesOnlyInjectedConfig(t *testing.T) {
t.Setenv("ZHINIAN_API_KEYS", "environment:must-not-be-read")
t.Setenv("ZHINIAN_INTERNAL_WORKER_TOKEN", "environment-worker-token")
t.Setenv("NODE_ENV", "production")
authenticator := publicapi.NewAuthenticator(publicapi.Config{})
request := httptest.NewRequest("GET", "/", nil)
request.Header.Set("authorization", "Bearer must-not-be-read")
_, _, err := authenticator.Authenticate(request)
assertAuthError(t, err, authErrorFixture{Status: 401, Message: "Invalid API key."})
if err := authenticator.AssertInternalWorker(httptest.NewRequest("POST", "/", nil)); err != nil {
t.Fatalf("development bypass with injected zero config error = %v", err)
}
}
func assertAuthError(t *testing.T, err error, want authErrorFixture) {
t.Helper()
if err == nil {
t.Fatal("error = nil, want typed authentication error")
}
authError, ok := err.(*publicapi.AuthError)
if !ok {
t.Fatalf("error type = %T, want *publicapi.AuthError", err)
}
if authError.Status != want.Status || authError.Message != want.Message || authError.Error() != want.Message {
t.Fatalf("error = %+v, want status=%d message=%q", authError, want.Status, want.Message)
}
}
func loadAuthFixture(t *testing.T) authFixture {
t.Helper()
data, err := os.ReadFile("../../../contracts/auth/public-api-auth-v1.json")
if err != nil {
t.Fatalf("read public API auth fixture: %v", err)
}
var fixture authFixture
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatalf("decode public API auth fixture: %v", err)
}
if fixture.Version != 1 {
t.Fatalf("fixture version = %d, want 1", fixture.Version)
}
return fixture
}