feat: add Go current-session HTTP adapter
This commit is contained in:
1 parent
c849077591
commit
772795e7eb
10 files changed
+1608
-7
No files matched your search
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres"
|
||||
)
|
||||
|
||||
@@ -14,6 +15,10 @@ type Options struct {
|
||||
Context context.Context
|
||||
Getenv postgres.Getenv
|
||||
ReadFile postgres.ReadFile
|
||||
// AuthorizationLoader is an optional Identity persistence Adapter used by
|
||||
// composition tests and alternate runtime backends. Production defaults to
|
||||
// the PostgreSQL Store opened below.
|
||||
AuthorizationLoader identity.AuthorizationSnapshotLoader
|
||||
}
|
||||
|
||||
type App struct {
|
||||
@@ -35,6 +40,10 @@ func New(options Options) (*App, error) {
|
||||
readFile = os.ReadFile
|
||||
}
|
||||
|
||||
authConfig, err := ParseAuthConfig(getenv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config, err := postgres.ParseConfig(getenv, readFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -43,10 +52,35 @@ func New(options Options) (*App, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closeOnError := true
|
||||
defer func() {
|
||||
if closeOnError {
|
||||
database.Close()
|
||||
}
|
||||
}()
|
||||
readiness := databaseReadiness{config: config, store: database.Store}
|
||||
var resolver httpapi.SessionResolver
|
||||
if authConfig.Configured {
|
||||
loader := options.AuthorizationLoader
|
||||
if loader == nil {
|
||||
loader = database.Store
|
||||
}
|
||||
resolver = identity.NewResolver(loader, authConfig.SessionSecret, "platform", nil)
|
||||
}
|
||||
authMe, err := httpapi.NewAuthMeHandler(httpapi.AuthState{
|
||||
Required: authConfig.Required, Configured: authConfig.Configured,
|
||||
}, resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
foundation := httpapi.NewHandler(readiness)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/api/auth/me", authMe)
|
||||
mux.Handle("/", foundation)
|
||||
closeOnError = false
|
||||
return &App{
|
||||
db: database,
|
||||
handler: httpapi.NewHandler(readiness),
|
||||
handler: mux,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package application_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/application"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
|
||||
)
|
||||
|
||||
func TestLocalApplicationServesFoundationHealthAndReadiness(t *testing.T) {
|
||||
@@ -66,3 +71,205 @@ func TestApplicationRejectsInvalidProductionDatabaseConfiguration(t *testing.T)
|
||||
t.Fatal("New() error = nil, want fail-closed database configuration error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationServesAnonymousCurrentSessionWithAuthConfigurationState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
environment map[string]string
|
||||
wantRequired bool
|
||||
wantConfigured bool
|
||||
}{
|
||||
{
|
||||
name: "trusted local development",
|
||||
environment: map[string]string{"ZHINIAN_DATA_BACKEND": "local"},
|
||||
wantRequired: false,
|
||||
wantConfigured: false,
|
||||
},
|
||||
{
|
||||
name: "production missing session secret",
|
||||
environment: map[string]string{
|
||||
"ZHINIAN_DATA_BACKEND": "local",
|
||||
"NODE_ENV": "production",
|
||||
},
|
||||
wantRequired: true,
|
||||
wantConfigured: false,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
app, err := application.New(application.Options{Getenv: applicationEnv(test.environment)})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
t.Cleanup(app.Close)
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"authenticated": false,
|
||||
"authRequired": test.wantRequired,
|
||||
"authConfigured": test.wantConfigured,
|
||||
"authMode": nil,
|
||||
"user": nil,
|
||||
}
|
||||
if !reflect.DeepEqual(payload, want) {
|
||||
t.Fatalf("payload = %#v, want %#v", payload, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationComposesSignedCookieResolverWithAuthorizationLoader(t *testing.T) {
|
||||
secret := "application-current-session-secret-with-enough-entropy"
|
||||
loader := &applicationAuthorizationLoader{snapshot: identity.AuthorizationSnapshot{
|
||||
Account: identity.AccountSnapshot{
|
||||
ID: "user-1", Phone: "13800138001", DisplayName: "Current User", Role: "user",
|
||||
OrganizationID: "org-1", Status: "active", SessionVersion: 7,
|
||||
},
|
||||
Organization: &identity.OrganizationSnapshot{ID: "org-1", Name: "Primary Organization", Status: "active"},
|
||||
}, found: true}
|
||||
app, err := application.New(application.Options{
|
||||
Getenv: applicationEnv(map[string]string{
|
||||
"ZHINIAN_DATA_BACKEND": "local",
|
||||
"ZHINIAN_AUTH_SESSION_SECRET": secret,
|
||||
}),
|
||||
AuthorizationLoader: loader,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
t.Cleanup(app.Close)
|
||||
|
||||
version := 7
|
||||
session := identity.Session{
|
||||
Version: 1, AuthMode: identity.AuthModeAdmin,
|
||||
IssuedAt: time.Now().Add(-time.Minute).Unix(), ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
SessionVersion: &version, AccessToken: "must-not-leak", TokenType: "bearer",
|
||||
User: identity.User{
|
||||
ID: "user-1", Subject: "forged", DisplayName: "Forged Admin", ClientID: "platform",
|
||||
Role: "super_admin", Status: "active", Authorities: []string{"ROLE_SUPER_ADMIN"}, Scope: []string{"forged"},
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal session: %v", err)
|
||||
}
|
||||
cookieValue, err := identity.Sign(raw, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("sign session: %v", err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: cookieValue})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
app.Handler().ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
body := response.Body.Bytes()
|
||||
var payload struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Authorities []string `json:"authorities"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !payload.Authenticated || payload.User.ID != "user-1" || payload.User.DisplayName != "Current User" || payload.User.Role != "user" {
|
||||
t.Fatalf("payload = %+v", payload)
|
||||
}
|
||||
if !reflect.DeepEqual(payload.User.Authorities, []string{"ROLE_USER"}) {
|
||||
t.Fatalf("authorities = %#v", payload.User.Authorities)
|
||||
}
|
||||
if len(loader.ids) != 1 || loader.ids[0] != "user-1" {
|
||||
t.Fatalf("loader IDs = %#v", loader.ids)
|
||||
}
|
||||
for _, forbidden := range []string{"accessToken", "tokenType", "sessionVersion", "expiresAt", "issuedAt", "must-not-leak"} {
|
||||
if strings.Contains(string(body), forbidden) {
|
||||
t.Fatalf("response leaked %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
|
||||
for _, method := range []string{http.MethodHead, http.MethodOptions, http.MethodPost} {
|
||||
methodRequest := httptest.NewRequest(method, "/api/auth/me", nil)
|
||||
methodResponse := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(methodResponse, methodRequest)
|
||||
wantStatus := http.StatusMethodNotAllowed
|
||||
if method == http.MethodHead {
|
||||
wantStatus = http.StatusOK
|
||||
}
|
||||
if method == http.MethodOptions {
|
||||
wantStatus = http.StatusNoContent
|
||||
}
|
||||
if methodResponse.Code != wantStatus {
|
||||
t.Fatalf("%s status = %d, want %d", method, methodResponse.Code, wantStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationUsesDatabaseAuthorizationAdapterByDefault(t *testing.T) {
|
||||
secret := "local-default-adapter-secret-with-enough-entropy"
|
||||
app, err := application.New(application.Options{Getenv: applicationEnv(map[string]string{
|
||||
"ZHINIAN_DATA_BACKEND": "local",
|
||||
"ZHINIAN_AUTH_SESSION_SECRET": secret,
|
||||
})})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
t.Cleanup(app.Close)
|
||||
|
||||
session := identity.Session{
|
||||
Version: 1, AuthMode: identity.AuthModeUser,
|
||||
IssuedAt: time.Now().Add(-time.Minute).Unix(), ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
User: identity.User{
|
||||
ID: "user-1", Subject: "user-1", DisplayName: "User", ClientID: "platform",
|
||||
Authorities: []string{}, Scope: []string{},
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal session: %v", err)
|
||||
}
|
||||
cookieValue, err := identity.Sign(raw, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("sign session: %v", err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: cookieValue})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
app.Handler().ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusInternalServerError || response.Body.Len() != 0 {
|
||||
t.Fatalf("response = %d %q, want empty 500 from unavailable local authorization adapter", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type applicationAuthorizationLoader struct {
|
||||
snapshot identity.AuthorizationSnapshot
|
||||
found bool
|
||||
err error
|
||||
ids []string
|
||||
}
|
||||
|
||||
func (loader *applicationAuthorizationLoader) FindAuthorizationSnapshot(_ context.Context, id string) (identity.AuthorizationSnapshot, bool, error) {
|
||||
loader.ids = append(loader.ids, id)
|
||||
return loader.snapshot, loader.found, loader.err
|
||||
}
|
||||
|
||||
func applicationEnv(values map[string]string) func(string) string {
|
||||
return func(name string) string { return values[name] }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AuthConfig struct {
|
||||
Required bool
|
||||
Configured bool
|
||||
SessionSecret string
|
||||
}
|
||||
|
||||
func ParseAuthConfig(getenv func(string) string) (AuthConfig, error) {
|
||||
if getenv == nil {
|
||||
return AuthConfig{}, errors.New("auth config getenv is required")
|
||||
}
|
||||
|
||||
sessionSecret := firstAuthEnv(getenv,
|
||||
"ZHINIAN_AUTH_SESSION_SECRET",
|
||||
"AUTH_SESSION_SECRET",
|
||||
"NEXTAUTH_SECRET",
|
||||
)
|
||||
hasSecret := sessionSecret != ""
|
||||
explicitRequired, hasExplicitRequired := authBool(getenv("ZHINIAN_AUTH_REQUIRED"))
|
||||
disabled, _ := authBool(getenv("ZHINIAN_AUTH_DISABLED"))
|
||||
|
||||
required := getenv("NODE_ENV") == "production" || hasSecret
|
||||
if hasExplicitRequired {
|
||||
required = explicitRequired
|
||||
}
|
||||
if disabled {
|
||||
required = false
|
||||
}
|
||||
|
||||
return AuthConfig{
|
||||
Required: required,
|
||||
Configured: (required || hasSecret) && hasSecret,
|
||||
SessionSecret: sessionSecret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func firstAuthEnv(getenv func(string) string, names ...string) string {
|
||||
for _, name := range names {
|
||||
if value := strings.TrimSpace(getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func authBool(value string) (bool, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true, true
|
||||
case "0", "false", "no", "off":
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package application_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/application"
|
||||
)
|
||||
|
||||
func TestParseAuthConfigMatchesCurrentSessionContract(t *testing.T) {
|
||||
type expectedConfig struct {
|
||||
Required bool `json:"required"`
|
||||
Configured bool `json:"configured"`
|
||||
SessionSecret *string `json:"sessionSecret"`
|
||||
}
|
||||
var fixture struct {
|
||||
AuthConfigurationCases []struct {
|
||||
Name string `json:"name"`
|
||||
Environment map[string]string `json:"environment"`
|
||||
Expected expectedConfig `json:"expected"`
|
||||
} `json:"authConfigurationCases"`
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("../../../contracts/auth/current-session-v1.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read current-session contract: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||
t.Fatalf("decode current-session contract: %v", err)
|
||||
}
|
||||
if len(fixture.AuthConfigurationCases) == 0 {
|
||||
t.Fatal("current-session contract has no authConfigurationCases")
|
||||
}
|
||||
|
||||
for _, testCase := range fixture.AuthConfigurationCases {
|
||||
t.Run(testCase.Name, func(t *testing.T) {
|
||||
config, err := application.ParseAuthConfig(authEnv(testCase.Environment))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAuthConfig() error = %v", err)
|
||||
}
|
||||
wantSecret := ""
|
||||
if testCase.Expected.SessionSecret != nil {
|
||||
wantSecret = *testCase.Expected.SessionSecret
|
||||
}
|
||||
want := application.AuthConfig{
|
||||
Required: testCase.Expected.Required,
|
||||
Configured: testCase.Expected.Configured,
|
||||
SessionSecret: wantSecret,
|
||||
}
|
||||
if config != want {
|
||||
t.Fatalf("config = %+v, want %+v", config, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthConfigRejectsNilGetenv(t *testing.T) {
|
||||
_, err := application.ParseAuthConfig(nil)
|
||||
if err == nil {
|
||||
t.Fatal("ParseAuthConfig(nil) error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthConfigDefaultsToDisabledAndUnconfigured(t *testing.T) {
|
||||
config, err := application.ParseAuthConfig(authEnv(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAuthConfig() error = %v", err)
|
||||
}
|
||||
want := application.AuthConfig{}
|
||||
if config != want {
|
||||
t.Fatalf("config = %+v, want %+v", config, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthConfigUsesFirstTrimmedSessionSecret(t *testing.T) {
|
||||
config, err := application.ParseAuthConfig(authEnv(map[string]string{
|
||||
"ZHINIAN_AUTH_SESSION_SECRET": " ",
|
||||
"AUTH_SESSION_SECRET": " auth-secret ",
|
||||
"NEXTAUTH_SECRET": "next-secret",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAuthConfig() error = %v", err)
|
||||
}
|
||||
want := application.AuthConfig{Required: true, Configured: true, SessionSecret: "auth-secret"}
|
||||
if config != want {
|
||||
t.Fatalf("config = %+v, want %+v", config, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthConfigRequiredPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values map[string]string
|
||||
want application.AuthConfig
|
||||
}{
|
||||
{
|
||||
name: "all accepted true values require auth",
|
||||
values: map[string]string{
|
||||
"ZHINIAN_AUTH_REQUIRED": " YeS ",
|
||||
},
|
||||
want: application.AuthConfig{Required: true},
|
||||
},
|
||||
{
|
||||
name: "all accepted false values disable auth",
|
||||
values: map[string]string{
|
||||
"ZHINIAN_AUTH_REQUIRED": " oFf ",
|
||||
"ZHINIAN_AUTH_SESSION_SECRET": "secret",
|
||||
},
|
||||
want: application.AuthConfig{Configured: true, SessionSecret: "secret"},
|
||||
},
|
||||
{
|
||||
name: "disabled true overrides explicit required true",
|
||||
values: map[string]string{
|
||||
"ZHINIAN_AUTH_DISABLED": "ON",
|
||||
"ZHINIAN_AUTH_REQUIRED": "1",
|
||||
"NEXTAUTH_SECRET": "secret",
|
||||
},
|
||||
want: application.AuthConfig{Configured: true, SessionSecret: "secret"},
|
||||
},
|
||||
{
|
||||
name: "disabled false does not override explicit required",
|
||||
values: map[string]string{
|
||||
"ZHINIAN_AUTH_DISABLED": "no",
|
||||
"ZHINIAN_AUTH_REQUIRED": "true",
|
||||
},
|
||||
want: application.AuthConfig{Required: true},
|
||||
},
|
||||
{
|
||||
name: "production defaults to required",
|
||||
values: map[string]string{
|
||||
"NODE_ENV": "production",
|
||||
},
|
||||
want: application.AuthConfig{Required: true},
|
||||
},
|
||||
{
|
||||
name: "secret defaults to required and configured",
|
||||
values: map[string]string{
|
||||
"NEXTAUTH_SECRET": " secret ",
|
||||
},
|
||||
want: application.AuthConfig{Required: true, Configured: true, SessionSecret: "secret"},
|
||||
},
|
||||
{
|
||||
name: "auto and invalid values use defaults",
|
||||
values: map[string]string{
|
||||
"ZHINIAN_AUTH_REQUIRED": "auto",
|
||||
"ZHINIAN_AUTH_DISABLED": "invalid",
|
||||
},
|
||||
want: application.AuthConfig{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
config, err := application.ParseAuthConfig(authEnv(test.values))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAuthConfig() error = %v", err)
|
||||
}
|
||||
if config != test.want {
|
||||
t.Fatalf("config = %+v, want %+v", config, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthConfigRecognizesEveryBooleanSpelling(t *testing.T) {
|
||||
for _, value := range []string{"1", "true", "yes", "on"} {
|
||||
t.Run("true_"+value, func(t *testing.T) {
|
||||
config, err := application.ParseAuthConfig(authEnv(map[string]string{"ZHINIAN_AUTH_REQUIRED": value}))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAuthConfig() error = %v", err)
|
||||
}
|
||||
if !config.Required {
|
||||
t.Fatalf("Required = false for %q, want true", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, value := range []string{"0", "false", "no", "off"} {
|
||||
t.Run("false_"+value, func(t *testing.T) {
|
||||
config, err := application.ParseAuthConfig(authEnv(map[string]string{
|
||||
"ZHINIAN_AUTH_REQUIRED": value,
|
||||
"ZHINIAN_AUTH_SESSION_SECRET": "secret",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAuthConfig() error = %v", err)
|
||||
}
|
||||
if config.Required {
|
||||
t.Fatalf("Required = true for %q, want false", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func authEnv(values map[string]string) func(string) string {
|
||||
return func(name string) string {
|
||||
return values[name]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user