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
@@ -0,0 +1,188 @@
package identity
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
var (
ErrTenantContextNotFound = errors.New("tenant context was not found")
ErrTenantContextUnavailable = errors.New("tenant context is unavailable")
)
type TenantContext struct {
ApplicationID string `json:"application_id"`
TenantID string `json:"tenant_id"`
DisplayName string `json:"display_name"`
Slug string `json:"slug"`
TenantStatus string `json:"tenant_status"`
TenantApplicationStatus string `json:"tenant_application_status"`
Version string `json:"version"`
UpdatedAt time.Time `json:"updated_at"`
ETag string `json:"-"`
}
func (context TenantContext) Active() bool {
return context.TenantStatus == "active" && context.TenantApplicationStatus == "active"
}
type TenantContextCredentialProvider func(context.Context) (string, []byte, error)
type TenantContextClient struct {
issuer string
appEnv string
application string
endpoint string
audience string
scope string
credentials TenantContextCredentialProvider
client *http.Client
mutex sync.Mutex
token string
tokenExpiry time.Time
tokenURL string
}
func NewTenantContextClient(appEnv, issuer, applicationID, endpoint, audience, scope string, credentials TenantContextCredentialProvider, base *http.Client) (*TenantContextClient, error) {
issuer = strings.TrimRight(strings.TrimSpace(issuer), "/")
endpoint = strings.TrimSpace(endpoint)
applicationID = strings.TrimSpace(applicationID)
if validatePublicIdentityURL(issuer, appEnv) != nil ||
validatePublicIdentityURL(strings.ReplaceAll(endpoint, "{tenantId}", uuid.Nil.String()), appEnv) != nil ||
!strings.Contains(endpoint, "{tenantId}") || uuid.Validate(applicationID) != nil ||
audience != "urn:easyai:auth-center:tenant-context" || scope != "tenant.context.read" || credentials == nil {
return nil, errors.New("tenant context configuration is invalid")
}
if base == nil {
base = &http.Client{Timeout: 10 * time.Second}
}
client := *base
if client.Timeout <= 0 {
client.Timeout = 10 * time.Second
}
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return &TenantContextClient{
issuer: issuer, appEnv: appEnv, application: applicationID, endpoint: endpoint,
audience: audience, scope: scope, credentials: credentials, client: &client,
}, nil
}
func (client *TenantContextClient) Get(ctx context.Context, tenantID, etag string) (TenantContext, bool, error) {
if uuid.Validate(strings.TrimSpace(tenantID)) != nil {
return TenantContext{}, false, ErrTenantContextNotFound
}
token, err := client.accessToken(ctx)
if err != nil {
return TenantContext{}, false, ErrTenantContextUnavailable
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.ReplaceAll(client.endpoint, "{tenantId}", tenantID), nil)
if err != nil {
return TenantContext{}, false, ErrTenantContextUnavailable
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "Bearer "+token)
if strings.TrimSpace(etag) != "" {
request.Header.Set("If-None-Match", strings.TrimSpace(etag))
}
response, err := client.client.Do(request)
if err != nil {
return TenantContext{}, false, ErrTenantContextUnavailable
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotModified {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
return TenantContext{}, true, nil
}
if response.StatusCode == http.StatusNotFound {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
return TenantContext{}, false, ErrTenantContextNotFound
}
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
return TenantContext{}, false, ErrTenantContextUnavailable
}
var output TenantContext
decoder := json.NewDecoder(io.LimitReader(response.Body, maxOnboardingResponseBytes))
if decoder.Decode(&output) != nil || output.ApplicationID != client.application || output.TenantID != tenantID ||
strings.TrimSpace(output.DisplayName) == "" || strings.TrimSpace(output.Slug) == "" || strings.TrimSpace(output.Version) == "" ||
output.UpdatedAt.IsZero() {
return TenantContext{}, false, ErrTenantContextUnavailable
}
output.ETag = strings.TrimSpace(response.Header.Get("ETag"))
return output, false, nil
}
func (client *TenantContextClient) accessToken(ctx context.Context) (string, error) {
client.mutex.Lock()
defer client.mutex.Unlock()
if client.token != "" && time.Now().Add(30*time.Second).Before(client.tokenExpiry) {
return client.token, nil
}
if client.tokenURL == "" {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.issuer+"/.well-known/openid-configuration", nil)
if err != nil {
return "", err
}
request.Header.Set("Accept", "application/json")
response, err := client.client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
var discovery struct {
Issuer string `json:"issuer"`
TokenEndpoint string `json:"token_endpoint"`
}
if response.StatusCode != http.StatusOK ||
json.NewDecoder(io.LimitReader(response.Body, maxOnboardingResponseBytes)).Decode(&discovery) != nil ||
strings.TrimRight(discovery.Issuer, "/") != client.issuer ||
validatePublicIdentityURL(discovery.TokenEndpoint, client.appEnv) != nil {
return "", errors.New("tenant context discovery is invalid")
}
client.tokenURL = discovery.TokenEndpoint
}
clientID, secret, err := client.credentials(ctx)
if err != nil {
return "", err
}
defer clear(secret)
if strings.TrimSpace(clientID) == "" || len(secret) < 16 {
return "", errors.New("tenant context credential is unavailable")
}
form := url.Values{"grant_type": {"client_credentials"}, "scope": {client.scope}}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
request.SetBasicAuth(clientID, string(secret))
response, err := client.client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
var tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if response.StatusCode != http.StatusOK ||
json.NewDecoder(io.LimitReader(response.Body, maxOnboardingResponseBytes)).Decode(&tokenResponse) != nil ||
tokenResponse.AccessToken == "" || !strings.EqualFold(tokenResponse.TokenType, "Bearer") ||
tokenResponse.ExpiresIn <= 0 {
return "", errors.New("tenant context token response is invalid")
}
client.token = tokenResponse.AccessToken
client.tokenExpiry = time.Now().Add(time.Duration(tokenResponse.ExpiresIn) * time.Second)
return client.token, nil
}