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