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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user