新增统一认证 Revision 状态机、单 Active 数据库约束、SecretStore 引用字段、Break-glass 与本地租户门禁,并在关键身份变化或禁用时清理旧 BFF Session。\n\n同时实现标准应用接入 Manifest v1 消费端,接入码只进入请求 Body,Exchange Token 只进入 Authorization Header,禁用重定向并限制响应大小。\n\n验证:go test ./...;go vet ./...
301 lines
11 KiB
Go
301 lines
11 KiB
Go
package identity
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const maxOnboardingResponseBytes = 1 << 20
|
|
|
|
type ExchangeStatus string
|
|
|
|
const (
|
|
ExchangeMetadataPending ExchangeStatus = "metadata_pending"
|
|
ExchangePreparing ExchangeStatus = "preparing"
|
|
ExchangeReady ExchangeStatus = "ready"
|
|
ExchangeCredentialDelivered ExchangeStatus = "credential_delivered"
|
|
ExchangeCompleted ExchangeStatus = "completed"
|
|
ExchangeFailed ExchangeStatus = "failed"
|
|
ExchangeExpired ExchangeStatus = "expired"
|
|
)
|
|
|
|
type ClaimedExchange struct {
|
|
ExchangeID string `json:"exchange_id"`
|
|
ExchangeToken string `json:"exchange_token"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
Version int64 `json:"version"`
|
|
}
|
|
|
|
type Exchange struct {
|
|
ExchangeID string `json:"exchange_id"`
|
|
ApplicationID string `json:"application_id"`
|
|
Status ExchangeStatus `json:"status"`
|
|
Version int64 `json:"version"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
LastErrorCategory string `json:"last_error_category,omitempty"`
|
|
AuditID string `json:"audit_id,omitempty"`
|
|
}
|
|
|
|
type ManifestClient struct {
|
|
ClientID string `json:"client_id"`
|
|
}
|
|
|
|
type ManifestClients struct {
|
|
BrowserLogin *ManifestClient `json:"browser_login,omitempty"`
|
|
MachineToMachine *ManifestClient `json:"machine_to_machine,omitempty"`
|
|
}
|
|
|
|
type ManifestSecurityEvents struct {
|
|
TransmitterIssuer string `json:"transmitter_issuer"`
|
|
ConfigurationEndpoint string `json:"configuration_endpoint"`
|
|
Audience string `json:"audience"`
|
|
}
|
|
|
|
type ManifestV1 struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Issuer string `json:"issuer"`
|
|
TenantID string `json:"tenant_id"`
|
|
ApplicationID string `json:"application_id"`
|
|
Capabilities []string `json:"capabilities"`
|
|
Audience string `json:"audience,omitempty"`
|
|
Scopes []string `json:"scopes"`
|
|
Clients ManifestClients `json:"clients"`
|
|
SecurityEvents *ManifestSecurityEvents `json:"security_events,omitempty"`
|
|
}
|
|
|
|
type MachineCredential struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientSecret string `json:"client_secret"`
|
|
IssuedAt time.Time `json:"issued_at"`
|
|
}
|
|
|
|
type CredentialDelivery struct {
|
|
Manifest ManifestV1 `json:"manifest"`
|
|
MachineCredential *MachineCredential `json:"machine_credential,omitempty"`
|
|
Version int64 `json:"-"`
|
|
}
|
|
|
|
func (manifest ManifestV1) Validate() error {
|
|
if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer) != nil {
|
|
return errors.New("application manifest identity metadata is invalid")
|
|
}
|
|
if _, err := uuid.Parse(manifest.TenantID); err != nil {
|
|
return errors.New("application manifest tenant is invalid")
|
|
}
|
|
if _, err := uuid.Parse(manifest.ApplicationID); err != nil {
|
|
return errors.New("application manifest application is invalid")
|
|
}
|
|
allowed := map[string]bool{"oidc_login": true, "api_access": true, "machine_to_machine": true, "token_introspection": true, "session_revocation": true}
|
|
capabilities := make(map[string]bool, len(manifest.Capabilities))
|
|
for _, capability := range manifest.Capabilities {
|
|
if !allowed[capability] || capabilities[capability] {
|
|
return errors.New("application manifest capabilities are invalid")
|
|
}
|
|
capabilities[capability] = true
|
|
}
|
|
if capabilities["session_revocation"] && !capabilities["token_introspection"] ||
|
|
capabilities["token_introspection"] && !capabilities["machine_to_machine"] {
|
|
return errors.New("application manifest capability dependencies are invalid")
|
|
}
|
|
if capabilities["oidc_login"] && (manifest.Clients.BrowserLogin == nil || strings.TrimSpace(manifest.Clients.BrowserLogin.ClientID) == "") {
|
|
return errors.New("application manifest browser client is missing")
|
|
}
|
|
if capabilities["machine_to_machine"] && (manifest.Clients.MachineToMachine == nil || strings.TrimSpace(manifest.Clients.MachineToMachine.ClientID) == "") {
|
|
return errors.New("application manifest machine client is missing")
|
|
}
|
|
if capabilities["api_access"] && strings.TrimSpace(manifest.Audience) == "" {
|
|
return errors.New("application manifest audience is missing")
|
|
}
|
|
if capabilities["session_revocation"] {
|
|
if manifest.SecurityEvents == nil || validatePublicIdentityURL(manifest.SecurityEvents.TransmitterIssuer) != nil ||
|
|
validatePublicIdentityURL(manifest.SecurityEvents.ConfigurationEndpoint) != nil || strings.TrimSpace(manifest.SecurityEvents.Audience) == "" {
|
|
return errors.New("application manifest security event metadata is invalid")
|
|
}
|
|
}
|
|
seenScopes := make(map[string]bool, len(manifest.Scopes))
|
|
for _, scope := range manifest.Scopes {
|
|
scope = strings.TrimSpace(scope)
|
|
if scope == "" || seenScopes[scope] {
|
|
return errors.New("application manifest scopes are invalid")
|
|
}
|
|
seenScopes[scope] = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePublicIdentityURL(raw string) error {
|
|
_, err := exactBaseURL(raw)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
// Discovery endpoints may contain a path, but must retain the same URL
|
|
// safety constraints as a base URL.
|
|
parsed, parseErr := http.NewRequest(http.MethodGet, strings.TrimSpace(raw), nil)
|
|
if parseErr != nil || parsed.URL.User != nil || parsed.URL.Host == "" || parsed.URL.RawQuery != "" || parsed.URL.Fragment != "" {
|
|
return errors.New("identity URL is invalid")
|
|
}
|
|
scheme := strings.ToLower(parsed.URL.Scheme)
|
|
if scheme == "https" || scheme == "http" && isLoopbackHost(strings.ToLower(parsed.URL.Hostname())) {
|
|
return nil
|
|
}
|
|
return errors.New("identity URL must use HTTPS")
|
|
}
|
|
|
|
type OnboardingClient struct {
|
|
baseURL string
|
|
client *http.Client
|
|
}
|
|
|
|
func NewOnboardingClient(baseURL string, base *http.Client) (*OnboardingClient, error) {
|
|
normalized, err := exactBaseURL(baseURL)
|
|
if err != nil {
|
|
return nil, errors.New("auth center URL 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 &OnboardingClient{baseURL: normalized, client: &client}, nil
|
|
}
|
|
|
|
func (client *OnboardingClient) Claim(ctx context.Context, code string) (ClaimedExchange, error) {
|
|
var output ClaimedExchange
|
|
status, _, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges", "", "", 0,
|
|
map[string]string{"onboarding_code": strings.TrimSpace(code)}, &output)
|
|
if err != nil || status != http.StatusCreated || output.Version < 1 || output.ExchangeID == "" || output.ExchangeToken == "" {
|
|
return ClaimedExchange{}, onboardingProtocolError(err)
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
func (client *OnboardingClient) SubmitMetadata(ctx context.Context, claimed ClaimedExchange, metadata ConsumerMetadata, idempotencyKey string) (Exchange, error) {
|
|
var output Exchange
|
|
status, _, err := client.request(ctx, http.MethodPut, "/api/v1/onboarding-exchanges/"+claimed.ExchangeID+"/metadata",
|
|
claimed.ExchangeToken, idempotencyKey, claimed.Version, metadata, &output)
|
|
if err != nil || status != http.StatusAccepted || output.Version < 1 {
|
|
return Exchange{}, onboardingProtocolError(err)
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
func (client *OnboardingClient) Get(ctx context.Context, exchangeID, exchangeToken string) (Exchange, error) {
|
|
var output Exchange
|
|
status, _, err := client.request(ctx, http.MethodGet, "/api/v1/onboarding-exchanges/"+exchangeID, exchangeToken, "", 0, nil, &output)
|
|
if err != nil || status != http.StatusOK || output.Version < 1 {
|
|
return Exchange{}, onboardingProtocolError(err)
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
func (client *OnboardingClient) DeliverCredential(ctx context.Context, exchange Exchange, exchangeToken, idempotencyKey string) (CredentialDelivery, error) {
|
|
var output CredentialDelivery
|
|
status, etag, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges/"+exchange.ExchangeID+"/credential-deliveries",
|
|
exchangeToken, idempotencyKey, exchange.Version, nil, &output)
|
|
if err != nil || status != http.StatusOK {
|
|
return CredentialDelivery{}, onboardingProtocolError(err)
|
|
}
|
|
if err := output.Manifest.Validate(); err != nil {
|
|
return CredentialDelivery{}, err
|
|
}
|
|
output.Version, err = parseWeakETag(etag)
|
|
if err != nil {
|
|
return CredentialDelivery{}, errors.New("onboarding response ETag is invalid")
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
func (client *OnboardingClient) Complete(ctx context.Context, exchangeID, exchangeToken, idempotencyKey string, version int64) error {
|
|
status, _, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges/"+exchangeID+"/completion",
|
|
exchangeToken, idempotencyKey, version, nil, nil)
|
|
if err != nil || status != http.StatusNoContent {
|
|
return onboardingProtocolError(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (client *OnboardingClient) request(ctx context.Context, method, path, token, idempotencyKey string, version int64, body, output any) (int, string, error) {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
reader = bytes.NewReader(payload)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, method, client.baseURL+path, reader)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if token != "" {
|
|
request.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
if idempotencyKey != "" {
|
|
request.Header.Set("Idempotency-Key", idempotencyKey)
|
|
}
|
|
if version > 0 {
|
|
request.Header.Set("If-Match", fmt.Sprintf(`W/"%d"`, version))
|
|
}
|
|
response, err := client.client.Do(request)
|
|
if err != nil {
|
|
return 0, "", errors.New("onboarding endpoint request failed")
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode >= 300 && response.StatusCode < 400 {
|
|
return response.StatusCode, "", errors.New("onboarding endpoint redirect is forbidden")
|
|
}
|
|
if output == nil || response.StatusCode == http.StatusNoContent {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
|
|
return response.StatusCode, response.Header.Get("ETag"), nil
|
|
}
|
|
payload, err := io.ReadAll(io.LimitReader(response.Body, maxOnboardingResponseBytes+1))
|
|
if err != nil || len(payload) > maxOnboardingResponseBytes {
|
|
return response.StatusCode, "", errors.New("onboarding endpoint response is invalid")
|
|
}
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return response.StatusCode, "", errors.New("onboarding endpoint rejected the request")
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(output); err != nil {
|
|
return response.StatusCode, "", errors.New("onboarding endpoint response is invalid")
|
|
}
|
|
return response.StatusCode, response.Header.Get("ETag"), nil
|
|
}
|
|
|
|
func onboardingProtocolError(cause error) error {
|
|
if cause == nil {
|
|
return errors.New("onboarding endpoint returned an unexpected status")
|
|
}
|
|
return cause
|
|
}
|
|
|
|
func parseWeakETag(value string) (int64, error) {
|
|
value = strings.TrimSpace(value)
|
|
if !strings.HasPrefix(value, `W/"`) || !strings.HasSuffix(value, `"`) {
|
|
return 0, errors.New("invalid ETag")
|
|
}
|
|
version, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(value, `W/"`), `"`), 10, 64)
|
|
if err != nil || version < 1 {
|
|
return 0, errors.New("invalid ETag")
|
|
}
|
|
return version, nil
|
|
}
|