fix(identity): 支持平台用户显式登录 AI Gateway

修复多租户身份配置将所有人类登录都强制解释为租户上下文的问题。Web 现在提供平台与租户两个受控入口,API 严格绑定 context_type、tid、issuer、application 和 subject,并为平台用户建立独立本地投影与可刷新会话。\n\n风险:新增会话身份列保持旧会话可读,新会话一律使用严格约束;未改变租户数据隔离和 API Key 行为。\n\n验证:Go 全量测试与 go vet 通过;PostgreSQL 平台投影、会话和安全事件集成测试通过;前端 lint、141 项测试与生产 build 通过;OpenAPI 生成和迁移安全测试通过。
This commit is contained in:
2026-07-31 14:24:40 +08:00
parent 88c971564a
commit c0296dbf06
29 changed files with 1061 additions and 84 deletions
@@ -20,18 +20,37 @@ type LoginTransaction struct {
Nonce string `json:"nonce"`
PKCEVerifier string `json:"pkceVerifier"`
ReturnTo string `json:"returnTo"`
ContextType string `json:"contextType"`
TenantHint string `json:"tenantHint,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
func NewLoginTransaction(returnTo string, now time.Time) (LoginTransaction, error) {
return NewLoginTransactionWithTenantHint(returnTo, "", now)
return NewLoginTransactionWithContext(returnTo, "tenant", "", now)
}
func NewLoginTransactionWithTenantHint(returnTo, tenantHint string, now time.Time) (LoginTransaction, error) {
return NewLoginTransactionWithContext(returnTo, "tenant", tenantHint, now)
}
func NewLoginTransactionWithContext(
returnTo, contextType, tenantHint string,
now time.Time,
) (LoginTransaction, error) {
if !ValidReturnTo(returnTo) {
return LoginTransaction{}, errors.New("returnTo must be a same-origin relative path")
}
contextType = strings.TrimSpace(contextType)
tenantHint = strings.TrimSpace(tenantHint)
if contextType != "platform" && contextType != "tenant" {
return LoginTransaction{}, errors.New("contextType must be platform or tenant")
}
if contextType == "platform" && tenantHint != "" {
return LoginTransaction{}, errors.New("platform context cannot bind a tenant hint")
}
if tenantHint != "" && uuid.Validate(tenantHint) != nil {
return LoginTransaction{}, errors.New("tenantHint must be a UUID")
}
state, err := randomBase64URL(32)
if err != nil {
return LoginTransaction{}, err
@@ -46,7 +65,8 @@ func NewLoginTransactionWithTenantHint(returnTo, tenantHint string, now time.Tim
}
return LoginTransaction{
State: state, Nonce: nonce, PKCEVerifier: verifier,
ReturnTo: returnTo, TenantHint: strings.TrimSpace(tenantHint), CreatedAt: now.UTC(),
ReturnTo: returnTo, ContextType: contextType,
TenantHint: tenantHint, CreatedAt: now.UTC(),
}, nil
}
@@ -68,6 +88,8 @@ func (c *Cipher) DecodeLoginTransaction(encoded string, now time.Time) (LoginTra
return LoginTransaction{}, err
}
if transaction.State == "" || transaction.Nonce == "" || transaction.PKCEVerifier == "" || !ValidReturnTo(transaction.ReturnTo) ||
transaction.ContextType != "platform" && transaction.ContextType != "tenant" ||
transaction.ContextType == "platform" && transaction.TenantHint != "" ||
transaction.TenantHint != "" && uuid.Validate(transaction.TenantHint) != nil ||
transaction.CreatedAt.IsZero() || now.Before(transaction.CreatedAt.Add(-time.Minute)) || !now.Before(transaction.CreatedAt.Add(10*time.Minute)) {
return LoginTransaction{}, errors.New("OIDC login transaction has expired or is invalid")
@@ -37,7 +37,7 @@ func TestLoginTransactionEncryptsTenantHint(t *testing.T) {
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
cipher, _ := NewCipher(bytes.Repeat([]byte{9}, 32))
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
transaction, err := NewLoginTransactionWithTenantHint("/", tenantHint, now)
transaction, err := NewLoginTransactionWithContext("/", "tenant", tenantHint, now)
if err != nil {
t.Fatal(err)
}
@@ -49,11 +49,30 @@ func TestLoginTransactionEncryptsTenantHint(t *testing.T) {
t.Fatal("login transaction cookie contains plaintext tenant hint")
}
decoded, err := cipher.DecodeLoginTransaction(encoded, now)
if err != nil || decoded.TenantHint != tenantHint {
if err != nil || decoded.ContextType != "tenant" || decoded.TenantHint != tenantHint {
t.Fatalf("decoded transaction=%+v err=%v", decoded, err)
}
}
func TestLoginTransactionRejectsInvalidContextBinding(t *testing.T) {
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
for _, test := range []struct {
contextType string
tenantHint string
}{
{contextType: ""},
{contextType: "account"},
{contextType: "platform", tenantHint: tenantHint},
} {
if _, err := NewLoginTransactionWithContext(
"/", test.contextType, test.tenantHint, now,
); err == nil {
t.Fatalf("unsafe context binding accepted: %#v", test)
}
}
}
func TestValidReturnToRejectsOpenRedirects(t *testing.T) {
for _, value := range []string{"https://evil.example", "//evil.example", "/\\evil", "", "workspace"} {
if ValidReturnTo(value) {
+19 -4
View File
@@ -120,6 +120,13 @@ func (s *Service) Create(ctx context.Context, bundle TokenBundle, localUser *aut
if verified.ID != localUser.ID {
return "", newSessionCreationError(sessionCreationIdentityMismatch, ErrSessionInvalid)
}
if verified.ContextType != localUser.ContextType ||
verified.TenantID != localUser.TenantID {
return "", newSessionCreationError(
sessionCreationIdentityMismatch,
ErrSessionInvalid,
)
}
if localUser.OIDCUserBindingID != "" &&
(localUser.Issuer == "" || localUser.ApplicationID == "" || localUser.TenantID == "" || localUser.OIDCClientID == "" ||
verified.Issuer != localUser.Issuer || verified.ApplicationID != localUser.ApplicationID ||
@@ -142,7 +149,9 @@ func (s *Service) Create(ctx context.Context, bundle TokenBundle, localUser *aut
_, err = s.repository.CreateOIDCSession(ctx, store.CreateOIDCSessionInput{
SessionTokenHash: hash, GatewayUserID: localUser.GatewayUserID, GatewayTenantID: localUser.GatewayTenantID,
OIDCUserBindingID: localUser.OIDCUserBindingID, OIDCClientID: verified.OIDCClientID,
Issuer: verified.Issuer, ApplicationID: verified.ApplicationID, TenantID: verified.TenantID,
Issuer: verified.Issuer, ApplicationID: verified.ApplicationID,
ContextType: verified.ContextType, Subject: verified.ID,
TenantID: verified.TenantID,
TokenCiphertext: ciphertext, AccessTokenExpiresAt: verified.TokenExpiresAt,
LastSeenAt: now, IdleExpiresAt: now.Add(s.config.IdleTTL), AbsoluteExpiresAt: now.Add(s.config.AbsoluteTTL),
})
@@ -310,9 +319,15 @@ func (s *Service) verifySessionUser(ctx context.Context, record store.OIDCSessio
if err != nil || user == nil || user.Source != "oidc" || user.ID != record.ExternalUserID {
return nil, ErrSessionInvalid
}
if record.OIDCUserBindingID != "" &&
(user.Issuer != record.Issuer || user.ApplicationID != record.ApplicationID ||
user.TenantID != record.TenantID || user.OIDCClientID != record.OIDCClientID) {
identityBound := record.ContextType != "" ||
record.OIDCUserBindingID != ""
if identityBound &&
(user.Issuer != record.Issuer ||
user.ApplicationID != record.ApplicationID ||
user.TenantID != record.TenantID ||
user.OIDCClientID != record.OIDCClientID ||
record.ContextType != "" &&
user.ContextType != record.ContextType) {
return nil, ErrSessionInvalid
}
return user, nil
+54 -2
View File
@@ -292,6 +292,53 @@ func TestServiceBindsMultiTenantSessionToIssuerApplicationTenantAndClient(t *tes
}
}
func TestServiceBindsPlatformSessionToExplicitContextWithoutTenant(t *testing.T) {
now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
applicationID := "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
verified := &auth.User{
ID: "platform-subject", Source: "oidc",
Issuer: "https://auth.example.test",
ApplicationID: applicationID, ContextType: "platform",
OIDCClientID: "gateway-browser",
TokenExpiresAt: now.Add(5 * time.Minute),
}
repository := newFakeRepository("platform-subject")
service := newTestService(
t,
repository,
fakeVerifier{users: map[string]*auth.User{"access": verified}},
&fakePublicClient{},
)
service.now = func() time.Time { return now }
raw, err := service.Create(
context.Background(),
TokenBundle{AccessToken: "access", RefreshToken: "refresh"},
&auth.User{
ID: "platform-subject",
GatewayUserID: "11111111-1111-4111-8111-111111111111",
GatewayTenantID: "22222222-2222-4222-8222-222222222222",
ContextType: "platform",
},
)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
record := repository.snapshot()
if record.ContextType != "platform" || record.TenantID != "" ||
record.Issuer != verified.Issuer ||
record.ApplicationID != applicationID {
t.Fatalf("platform session record=%#v", record)
}
verified.ContextType = "tenant"
verified.TenantID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
if _, err := service.Resolve(
context.Background(),
raw,
); !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("Resolve() error = %v, want ErrSessionInvalid", err)
}
}
func TestServiceCreateReportsSafeFailureCategory(t *testing.T) {
now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC)
validVerified := &auth.User{
@@ -445,11 +492,16 @@ func (f *fakeRepository) CreateOIDCSession(_ context.Context, input store.Create
if f.createError != nil {
return store.OIDCSession{}, f.createError
}
external := input.Subject
if external == "" {
external = f.external
}
f.record = store.OIDCSession{
ID: "33333333-3333-4333-8333-333333333333", SessionTokenHash: append([]byte(nil), input.SessionTokenHash...),
GatewayUserID: input.GatewayUserID, GatewayTenantID: input.GatewayTenantID, ExternalUserID: f.external,
GatewayUserID: input.GatewayUserID, GatewayTenantID: input.GatewayTenantID, ExternalUserID: external,
OIDCUserBindingID: input.OIDCUserBindingID, OIDCClientID: input.OIDCClientID,
Issuer: input.Issuer, ApplicationID: input.ApplicationID, TenantID: input.TenantID,
Issuer: input.Issuer, ApplicationID: input.ApplicationID,
ContextType: input.ContextType, TenantID: input.TenantID,
UserStatus: "active", TokenCiphertext: append([]byte(nil), input.TokenCiphertext...), AccessTokenExpiresAt: input.AccessTokenExpiresAt,
LastSeenAt: input.LastSeenAt, IdleExpiresAt: input.IdleExpiresAt, AbsoluteExpiresAt: input.AbsoluteExpiresAt, RefreshVersion: 1,
}