feat(identity): 接入认证中心多租户登录

支持 Manifest V2 动态 tid 验证、Tenant Context 同步和租户内 JIT 投影,并保留 Manifest V1 与旧 Session 兼容。\n\n增加 tenantHint、租户切换、普通注册关闭及 application/principal/tenant 两级 SSF 撤销;迁移、定向安全测试和本地双租户跨仓 E2E 已通过。\n\nrelease_required=true;未执行 Release、Staging 或真实链路。
This commit is contained in:
2026-07-28 17:28:35 +08:00
parent 0b02e62c72
commit 5c679ff13f
45 changed files with 2986 additions and 139 deletions
@@ -7,6 +7,8 @@ import (
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
const LoginTransactionCookieName = "easyai_gateway_oidc_login"
@@ -18,10 +20,15 @@ type LoginTransaction struct {
Nonce string `json:"nonce"`
PKCEVerifier string `json:"pkceVerifier"`
ReturnTo string `json:"returnTo"`
TenantHint string `json:"tenantHint,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
func NewLoginTransaction(returnTo string, now time.Time) (LoginTransaction, error) {
return NewLoginTransactionWithTenantHint(returnTo, "", now)
}
func NewLoginTransactionWithTenantHint(returnTo, tenantHint string, now time.Time) (LoginTransaction, error) {
if !ValidReturnTo(returnTo) {
return LoginTransaction{}, errors.New("returnTo must be a same-origin relative path")
}
@@ -37,7 +44,10 @@ func NewLoginTransaction(returnTo string, now time.Time) (LoginTransaction, erro
if err != nil {
return LoginTransaction{}, err
}
return LoginTransaction{State: state, Nonce: nonce, PKCEVerifier: verifier, ReturnTo: returnTo, CreatedAt: now.UTC()}, nil
return LoginTransaction{
State: state, Nonce: nonce, PKCEVerifier: verifier,
ReturnTo: returnTo, TenantHint: strings.TrimSpace(tenantHint), CreatedAt: now.UTC(),
}, nil
}
func (c *Cipher) EncodeLoginTransaction(transaction LoginTransaction) (string, error) {
@@ -58,6 +68,7 @@ func (c *Cipher) DecodeLoginTransaction(encoded string, now time.Time) (LoginTra
return LoginTransaction{}, err
}
if transaction.State == "" || transaction.Nonce == "" || transaction.PKCEVerifier == "" || !ValidReturnTo(transaction.ReturnTo) ||
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")
}
@@ -33,6 +33,27 @@ func TestLoginTransactionIsEncryptedAndBounded(t *testing.T) {
}
}
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)
if err != nil {
t.Fatal(err)
}
encoded, err := cipher.EncodeLoginTransaction(transaction)
if err != nil {
t.Fatal(err)
}
if strings.Contains(encoded, tenantHint) {
t.Fatal("login transaction cookie contains plaintext tenant hint")
}
decoded, err := cipher.DecodeLoginTransaction(encoded, now)
if err != nil || decoded.TenantHint != tenantHint {
t.Fatalf("decoded transaction=%+v err=%v", decoded, err)
}
}
func TestValidReturnToRejectsOpenRedirects(t *testing.T) {
for _, value := range []string{"https://evil.example", "//evil.example", "/\\evil", "", "workspace"} {
if ValidReturnTo(value) {
+16
View File
@@ -85,6 +85,12 @@ func (s *Service) Create(ctx context.Context, bundle TokenBundle, localUser *aut
if err != nil || verified == nil || verified.Source != "oidc" || verified.ID == "" || verified.ID != localUser.ID {
return "", ErrSessionInvalid
}
if localUser.OIDCUserBindingID != "" &&
(localUser.Issuer == "" || localUser.ApplicationID == "" || localUser.TenantID == "" || localUser.OIDCClientID == "" ||
verified.Issuer != localUser.Issuer || verified.ApplicationID != localUser.ApplicationID ||
verified.TenantID != localUser.TenantID || verified.OIDCClientID != localUser.OIDCClientID) {
return "", ErrSessionInvalid
}
now := s.now()
if !verified.TokenExpiresAt.After(now) {
return "", ErrSessionExpired
@@ -100,6 +106,8 @@ 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,
TokenCiphertext: ciphertext, AccessTokenExpiresAt: verified.TokenExpiresAt,
LastSeenAt: now, IdleExpiresAt: now.Add(s.config.IdleTTL), AbsoluteExpiresAt: now.Add(s.config.AbsoluteTTL),
})
@@ -142,6 +150,9 @@ func (s *Service) resolveRecord(ctx context.Context, hash []byte, record store.O
}
user, err := s.verifySessionUser(ctx, record, bundle.AccessToken)
if err != nil {
if errors.Is(err, ErrSessionInvalid) {
_ = s.repository.DeleteOIDCSessionByID(ctx, record.ID)
}
return nil, err
}
if err := s.touch(ctx, record, now); err != nil {
@@ -264,6 +275,11 @@ 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) {
return nil, ErrSessionInvalid
}
return user, nil
}
@@ -255,6 +255,42 @@ func TestServiceDeletesSessionEvenWhenCiphertextCannotBeDecryptedDuringLogout(t
}
}
func TestServiceBindsMultiTenantSessionToIssuerApplicationTenantAndClient(t *testing.T) {
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
tenantA := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
tenantB := "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
applicationID := "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
verified := &auth.User{
ID: "shared-subject", Source: "oidc", Issuer: "https://auth.example.test",
ApplicationID: applicationID, TenantID: tenantA, OIDCClientID: "gateway-browser",
TokenExpiresAt: now.Add(5 * time.Minute),
}
verifier := fakeVerifier{users: map[string]*auth.User{"access": verified}}
repository := newFakeRepository("shared-subject")
service := newTestService(t, repository, verifier, &fakePublicClient{})
service.now = func() time.Time { return now }
local := &auth.User{
ID: "shared-subject", GatewayUserID: "11111111-1111-4111-8111-111111111111",
GatewayTenantID: "22222222-2222-4222-8222-222222222222",
OIDCUserBindingID: "44444444-4444-4444-8444-444444444444",
Issuer: "https://auth.example.test", ApplicationID: applicationID,
TenantID: tenantA, OIDCClientID: "gateway-browser",
}
raw, err := service.Create(context.Background(), TokenBundle{
AccessToken: "access", RefreshToken: "refresh",
}, local)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
verified.TenantID = tenantB
if _, err := service.Resolve(context.Background(), raw); !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("Resolve() error = %v, want ErrSessionInvalid", err)
}
if !repository.isDeleted() {
t.Fatal("identity-binding mismatch did not destroy the session")
}
}
func testLocalUser() *auth.User {
return &auth.User{
ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111",
@@ -323,6 +359,8 @@ func (f *fakeRepository) CreateOIDCSession(_ context.Context, input store.Create
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,
OIDCUserBindingID: input.OIDCUserBindingID, OIDCClientID: input.OIDCClientID,
Issuer: input.Issuer, ApplicationID: input.ApplicationID, 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,
}