easyai-ai-gateway/apps/api/internal/identity/pairing_service.go
chengcheng 96bbd3a2f6 feat(identity): 实现接入码配对后台流程
Gateway 使用一次性接入码创建 Exchange,将 Exchange Token、机器凭据与 Session Key 仅写入 SecretStore,并持久化脱敏配对进度。\n\n资源就绪后先保存凭据和 Manifest,再自动准备 SSF,最后确认远端 Exchange;SecretStore 失败时保持 ready,重试会触发新 Secret 轮换,不回放旧值。\n\n验证:go test ./...;go vet ./...
2026-07-17 11:55:24 +08:00

302 lines
11 KiB
Go

package identity
import (
"context"
"crypto/rand"
"errors"
"strings"
"time"
"github.com/google/uuid"
)
type PairingStatus string
const (
PairingMetadataPending PairingStatus = "metadata_pending"
PairingPreparing PairingStatus = "preparing"
PairingReady PairingStatus = "ready"
PairingCredentialsSaved PairingStatus = "credentials_saved"
PairingCompleted PairingStatus = "completed"
PairingFailed PairingStatus = "failed"
PairingExpired PairingStatus = "expired"
)
type PairingExchange struct {
ID string `json:"id"`
RevisionID string `json:"revisionId"`
RemoteExchangeID string `json:"remoteExchangeId"`
ExchangeTokenRef string `json:"-"`
Status PairingStatus `json:"status"`
RemoteVersion int64 `json:"remoteVersion"`
ExpiresAt time.Time `json:"expiresAt"`
Version int64 `json:"version"`
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
AuthCenterAuditID string `json:"authCenterAuditId,omitempty"`
LastTraceID string `json:"lastTraceId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PairingExchangeUpdate struct {
Status PairingStatus
RemoteVersion int64
LastErrorCategory string
AuthCenterAuditID string
}
type PairingRepository interface {
CreateIdentityConfigurationRevision(context.Context, Revision) (Revision, error)
IdentityConfigurationRevision(context.Context, string) (Revision, error)
ApplyIdentityManifest(context.Context, string, int64, ManifestApplication) (Revision, error)
CreateIdentityPairingExchange(context.Context, PairingExchange) (PairingExchange, error)
IdentityPairingExchange(context.Context, string) (PairingExchange, error)
UpdateIdentityPairingExchange(context.Context, string, int64, PairingExchangeUpdate) (PairingExchange, error)
}
type PairingSecretStore interface {
Put(context.Context, string, []byte) error
Get(context.Context, string) ([]byte, error)
Delete(context.Context, string) error
}
type OnboardingRemote interface {
Claim(context.Context, string) (ClaimedExchange, error)
SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error)
Get(context.Context, string, string) (Exchange, error)
DeliverCredential(context.Context, Exchange, string, string) (CredentialDelivery, error)
Complete(context.Context, string, string, string, int64) error
}
type SecurityEventPreparer interface {
PrepareSecurityEvents(context.Context, Revision, []byte) error
}
type PairingService struct {
repository PairingRepository
secrets PairingSecretStore
remote func(string) (OnboardingRemote, error)
security SecurityEventPreparer
}
func NewPairingService(repository PairingRepository, secrets PairingSecretStore, remote func(string) (OnboardingRemote, error), security SecurityEventPreparer) *PairingService {
return &PairingService{repository: repository, secrets: secrets, remote: remote, security: security}
}
func (service *PairingService) Start(ctx context.Context, input PairingInput, traceID string) (PairingExchange, error) {
draft, err := NewDraft(input)
if err != nil {
return PairingExchange{}, err
}
draft.LastTraceID = strings.TrimSpace(traceID)
draft, err = service.repository.CreateIdentityConfigurationRevision(ctx, draft)
if err != nil {
return PairingExchange{}, err
}
remote, err := service.remote(draft.AuthCenterURL)
if err != nil {
return PairingExchange{}, err
}
claimed, err := remote.Claim(ctx, strings.TrimSpace(input.OnboardingCode))
if err != nil {
return PairingExchange{}, err
}
pairingID := uuid.NewString()
tokenReference := "identity-exchange-" + pairingID
if err := service.secrets.Put(ctx, tokenReference, []byte(claimed.ExchangeToken)); err != nil {
return PairingExchange{}, err
}
claimed.ExchangeToken = ""
pairing := PairingExchange{
ID: pairingID, RevisionID: draft.ID, RemoteExchangeID: claimed.ExchangeID, ExchangeTokenRef: tokenReference,
Status: PairingMetadataPending, RemoteVersion: claimed.Version, ExpiresAt: claimed.ExpiresAt,
Version: 1, LastTraceID: strings.TrimSpace(traceID),
}
pairing, err = service.repository.CreateIdentityPairingExchange(ctx, pairing)
if err != nil {
_ = service.secrets.Delete(ctx, tokenReference)
return PairingExchange{}, err
}
return pairing, nil
}
func (service *PairingService) Continue(ctx context.Context, pairingID string) (PairingExchange, error) {
for step := 0; step < 6; step++ {
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return PairingExchange{}, err
}
if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired {
return pairing, nil
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return PairingExchange{}, err
}
token, err := service.secrets.Get(ctx, pairing.ExchangeTokenRef)
if err != nil {
return PairingExchange{}, err
}
remote, err := service.remote(revision.AuthCenterURL)
if err != nil {
clear(token)
return PairingExchange{}, err
}
switch pairing.Status {
case PairingMetadataPending:
metadata, metadataErr := PairingInput{
AuthCenterURL: revision.AuthCenterURL, PublicBaseURL: revision.PublicBaseURL,
WebBaseURL: revision.WebBaseURL, LocalTenantKey: revision.LocalTenantKey,
}.ConsumerMetadata(true)
if metadataErr != nil {
clear(token)
return PairingExchange{}, metadataErr
}
view, submitErr := remote.SubmitMetadata(ctx, ClaimedExchange{
ExchangeID: pairing.RemoteExchangeID, ExchangeToken: string(token), ExpiresAt: pairing.ExpiresAt, Version: pairing.RemoteVersion,
}, metadata, "gateway-pairing-metadata-"+pairing.ID)
clear(token)
if submitErr != nil {
return PairingExchange{}, submitErr
}
if _, err := service.updateFromRemote(ctx, pairing, view); err != nil {
return PairingExchange{}, err
}
case PairingPreparing:
view, getErr := remote.Get(ctx, pairing.RemoteExchangeID, string(token))
clear(token)
if getErr != nil {
return PairingExchange{}, getErr
}
updated, err := service.updateFromRemote(ctx, pairing, view)
if err != nil {
return PairingExchange{}, err
}
if updated.Status == PairingPreparing {
return updated, nil
}
case PairingReady:
delivery, deliveryErr := remote.DeliverCredential(ctx, Exchange{
ExchangeID: pairing.RemoteExchangeID, ApplicationID: revision.ApplicationID,
Status: ExchangeReady, Version: pairing.RemoteVersion, ExpiresAt: pairing.ExpiresAt,
}, string(token), "gateway-pairing-credential-"+uuid.NewString())
clear(token)
if deliveryErr != nil {
return PairingExchange{}, deliveryErr
}
updatedRevision, saveErr := service.saveDelivery(ctx, revision, pairing, delivery)
if saveErr != nil {
return PairingExchange{}, saveErr
}
_ = updatedRevision
if _, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: PairingCredentialsSaved, RemoteVersion: delivery.Version,
}); err != nil {
return PairingExchange{}, err
}
case PairingCredentialsSaved:
if revision.SessionRevocation {
if service.security == nil {
clear(token)
return PairingExchange{}, errors.New("security event preparation is unavailable")
}
secret, secretErr := service.secrets.Get(ctx, revision.MachineCredentialRef)
if secretErr != nil {
clear(token)
return PairingExchange{}, secretErr
}
prepareErr := service.security.PrepareSecurityEvents(ctx, revision, secret)
clear(secret)
if prepareErr != nil {
clear(token)
return PairingExchange{}, prepareErr
}
}
completeErr := remote.Complete(ctx, pairing.RemoteExchangeID, string(token), "gateway-pairing-complete-"+pairing.ID, pairing.RemoteVersion)
clear(token)
if completeErr != nil {
return PairingExchange{}, completeErr
}
completed, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: PairingCompleted, RemoteVersion: pairing.RemoteVersion,
})
if err != nil {
return PairingExchange{}, err
}
_ = service.secrets.Delete(ctx, pairing.ExchangeTokenRef)
return completed, nil
default:
clear(token)
return PairingExchange{}, ErrRevisionConflict
}
}
return service.repository.IdentityPairingExchange(ctx, pairingID)
}
func (service *PairingService) saveDelivery(ctx context.Context, revision Revision, pairing PairingExchange, delivery CredentialDelivery) (Revision, error) {
if err := delivery.Manifest.Validate(); err != nil {
return Revision{}, err
}
machineReference := ""
if delivery.Manifest.Clients.MachineToMachine != nil {
if delivery.MachineCredential == nil || delivery.MachineCredential.ClientID != delivery.Manifest.Clients.MachineToMachine.ClientID {
return Revision{}, errors.New("onboarding machine credential is invalid")
}
machineReference = "identity-machine-" + revision.ID
secret := []byte(delivery.MachineCredential.ClientSecret)
delivery.MachineCredential.ClientSecret = ""
if err := service.secrets.Put(ctx, machineReference, secret); err != nil {
clear(secret)
return Revision{}, err
}
clear(secret)
}
sessionReference := ""
if delivery.Manifest.Clients.BrowserLogin != nil {
sessionReference = "identity-session-" + revision.ID
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
_ = service.secrets.Delete(ctx, machineReference)
return Revision{}, err
}
if err := service.secrets.Put(ctx, sessionReference, key); err != nil {
clear(key)
_ = service.secrets.Delete(ctx, machineReference)
return Revision{}, err
}
clear(key)
}
updated, err := service.repository.ApplyIdentityManifest(ctx, revision.ID, revision.Version, ManifestApplication{
Manifest: delivery.Manifest, MachineCredentialRef: machineReference, SessionEncryptionKeyRef: sessionReference,
TraceID: pairing.LastTraceID, AuditID: pairing.AuthCenterAuditID,
})
if err != nil {
if machineReference != "" {
_ = service.secrets.Delete(ctx, machineReference)
}
if sessionReference != "" {
_ = service.secrets.Delete(ctx, sessionReference)
}
return Revision{}, err
}
return updated, nil
}
func (service *PairingService) updateFromRemote(ctx context.Context, pairing PairingExchange, remote Exchange) (PairingExchange, error) {
status := PairingPreparing
switch remote.Status {
case ExchangeReady, ExchangeCredentialDelivered:
status = PairingReady
case ExchangeCompleted:
status = PairingFailed
remote.LastErrorCategory = "remote_state_invalid"
case ExchangeFailed:
status = PairingFailed
case ExchangeExpired:
status = PairingExpired
}
return service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: status, RemoteVersion: remote.Version, LastErrorCategory: remote.LastErrorCategory, AuthCenterAuditID: remote.AuditID,
})
}