feat: add shared backend authorization seams
This commit is contained in:
1 parent
d0207fcebe
commit
48dd5d07c8
9 files changed
+1086
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user