This commit is contained in:
+24
-1
@@ -36,6 +36,7 @@ import (
|
||||
"data-terminal-cloud/internal/memorytool"
|
||||
"data-terminal-cloud/internal/objectstore"
|
||||
"data-terminal-cloud/internal/observability"
|
||||
"data-terminal-cloud/internal/onboarding"
|
||||
"data-terminal-cloud/internal/opsaccess"
|
||||
"data-terminal-cloud/internal/opsconfig"
|
||||
"data-terminal-cloud/internal/opsdevice"
|
||||
@@ -130,7 +131,8 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
}
|
||||
tokenVerifier := identity.NewOIDCVerifier(ctx, cfg.AuthIssuerURL, cfg.AuthJWKSURL, cfg.AuthAudience)
|
||||
identityRepository := identity.NewRepository(databasePool)
|
||||
var deviceVerifier *deviceauth.Verifier
|
||||
var deviceVerifier httpapi.DeviceRequestVerifier
|
||||
var onboardingService *onboarding.Service
|
||||
var deviceRepository *device.Repository
|
||||
var deviceTelemetryService *device.TelemetryService
|
||||
var conversationCreator *orchestration.Service
|
||||
@@ -344,6 +346,22 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("create rate limiter: %w", err)
|
||||
}
|
||||
if (serviceName == "api" || serviceName == "worker") && cfg.Onboarding.MasterKey != "" {
|
||||
var sms onboarding.SMSProvider
|
||||
if cfg.Onboarding.SMSLoginEnabled {
|
||||
sms, err = onboarding.NewHTTPSMSProvider(cfg.Onboarding.SMSGatewayURL, cfg.Onboarding.SMSGatewayToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
onboardingService, err = onboarding.NewService(databasePool, redisClient, onboarding.Options{Enabled: cfg.Onboarding.Enabled, PauseClaims: cfg.Onboarding.PauseClaims, MasterKey: cfg.Onboarding.MasterKey, EntryURL: cfg.Onboarding.EntryURL, AllowLocalHTTP: cfg.Onboarding.AllowLocalHTTP, TrustedProxyCIDRs: cfg.Onboarding.TrustedProxyCIDRs, TokenVerifier: tokenVerifier, LegacyVerifier: deviceVerifier, ActivationEnabled: cfg.Onboarding.ActivationEnabled, ProviderMasterKey: cfg.VolcHardwareDeviceCredentialMasterKey, InstanceID: cfg.VolcHardwareInstanceID, ProductKey: cfg.VolcHardwareProductKey, ProductSecret: cfg.VolcHardwareProductSecret, BotID: cfg.VolcHardwareAgentBotID, Transport: cfg.VolcHardwareTransport, MinimumSDKVersion: cfg.VolcHardwareMinimumSDKVersion, SMS: sms, SMSLoginEnabled: cfg.Onboarding.SMSLoginEnabled, SelfSignupTenantIDs: cfg.Onboarding.SelfSignupTenantIDs, Logger: logger})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create onboarding service: %w", err)
|
||||
}
|
||||
if serviceName == "api" {
|
||||
deviceVerifier = onboardingService
|
||||
}
|
||||
}
|
||||
if serviceName == "worker" {
|
||||
webEventService = webevents.NewService(databasePool)
|
||||
outbox.RegisterMetrics(databasePool)
|
||||
@@ -354,6 +372,10 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
outbox.WorkerOptions{PollInterval: cfg.WorkerPollInterval},
|
||||
)
|
||||
outboxWorker.Register("platform.noop", func(context.Context, outbox.Event) error { return nil })
|
||||
if onboardingService != nil {
|
||||
outboxWorker.Register(onboarding.ActivationEvent, onboardingService.ProcessActivation)
|
||||
go onboardingService.RunMaintenance(ctx)
|
||||
}
|
||||
alertWebhookProcessor, processorErr := alertnotification.NewProcessor(cfg.AlertWebhookURL, cfg.AlertWebhookSecret, cfg.AlertWebhookTimeout)
|
||||
if processorErr != nil {
|
||||
return fmt.Errorf("create alert webhook processor: %w", processorErr)
|
||||
@@ -520,6 +542,7 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
}()
|
||||
}
|
||||
router := httpapi.NewRouter(httpapi.RouterOptions{
|
||||
OnboardingService: onboardingService,
|
||||
ServiceName: serviceName,
|
||||
Logger: logger,
|
||||
RateLimiter: rateLimiterAdapter{limiter},
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Onboarding OnboardingConfig
|
||||
VikingResourceID string
|
||||
VikingCollectionName string
|
||||
VikingAPIKey string
|
||||
@@ -254,6 +255,7 @@ func Load(serviceName string) (Config, error) {
|
||||
}
|
||||
environment := stringEnv("APP_ENV", EnvironmentLocal)
|
||||
secretValues, err := loadSecretValues(
|
||||
"ONBOARDING_MASTER_KEY", "ONBOARDING_SMS_GATEWAY_TOKEN",
|
||||
"DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "DEVICE_CREDENTIAL_MASTER_KEY", "MODEL_CREDENTIAL_MASTER_KEY", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET",
|
||||
"CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET", "PLATFORM_HEALTH_CALLBACK_SECRET",
|
||||
"VOLC_SUBTITLE_CALLBACK_SECRET", "VIKING_MEMORY_API_KEY",
|
||||
@@ -485,6 +487,10 @@ func Load(serviceName string) (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Onboarding, err = loadOnboardingConfig(secretValues)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
@@ -492,6 +498,9 @@ func Load(serviceName string) (Config, error) {
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if err := c.Onboarding.validate(c.Environment); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.SubtitleCallbackURL != "" || c.SubtitleCallbackSecret != "" || c.SubtitlePrimary {
|
||||
u, err := url.Parse(c.SubtitleCallbackURL)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil || len(c.SubtitleCallbackSecret) < 32 {
|
||||
@@ -607,7 +616,7 @@ func (c Config) Validate() error {
|
||||
if c.VolcHardwareTransport != "high_quality" && c.VolcHardwareTransport != "low_load" {
|
||||
return errors.New("VOLC_HARDWARE_AGENT_TRANSPORT must be high_quality or low_load")
|
||||
}
|
||||
if c.ServiceName == "api" && c.VolcHardwareProvisioningEnabled {
|
||||
if (c.ServiceName == "api" && c.VolcHardwareProvisioningEnabled) || ((c.ServiceName == "api" || c.ServiceName == "worker") && c.Onboarding.ActivationEnabled) {
|
||||
providerKey, err := hex.DecodeString(c.VolcHardwareDeviceCredentialMasterKey)
|
||||
if err != nil || len(providerKey) != 32 {
|
||||
return errors.New("VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY must be 32 bytes encoded as 64 hexadecimal characters")
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"github.com/google/uuid"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type OnboardingConfig struct {
|
||||
Enabled, PauseClaims, ActivationEnabled, SMSLoginEnabled, AllowLocalHTTP bool
|
||||
MasterKey, EntryURL, SMSGatewayURL, SMSGatewayToken string
|
||||
TrustedProxyCIDRs, SelfSignupTenantIDs []string
|
||||
}
|
||||
|
||||
func loadOnboardingConfig(secrets map[string]string) (OnboardingConfig, error) {
|
||||
c := OnboardingConfig{MasterKey: secrets["ONBOARDING_MASTER_KEY"], SMSGatewayToken: secrets["ONBOARDING_SMS_GATEWAY_TOKEN"], EntryURL: strings.TrimSpace(os.Getenv("ONBOARDING_ENTRY_URL")), SMSGatewayURL: strings.TrimSpace(os.Getenv("ONBOARDING_SMS_GATEWAY_URL"))}
|
||||
for name, target := range map[string]*bool{"ONBOARDING_ENABLED": &c.Enabled, "ONBOARDING_PAUSE_CLAIMS": &c.PauseClaims, "ONBOARDING_ACTIVATION_ENABLED": &c.ActivationEnabled, "ONBOARDING_SMS_LOGIN_ENABLED": &c.SMSLoginEnabled, "ONBOARDING_ALLOW_LOCAL_HTTP": &c.AllowLocalHTTP} {
|
||||
v, err := boolEnv(name, false)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
*target = v
|
||||
}
|
||||
split := func(name string) []string {
|
||||
items := []string{}
|
||||
for _, v := range strings.Split(os.Getenv(name), ",") {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
items = append(items, strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
c.TrustedProxyCIDRs = split("ONBOARDING_TRUSTED_PROXY_CIDRS")
|
||||
c.SelfSignupTenantIDs = split("ONBOARDING_SELF_SIGNUP_TENANT_IDS")
|
||||
return c, nil
|
||||
}
|
||||
func (c OnboardingConfig) validate(environment string) error {
|
||||
if c.AllowLocalHTTP && environment != EnvironmentLocal && environment != EnvironmentTest {
|
||||
return errors.New("ONBOARDING_ALLOW_LOCAL_HTTP is limited to local/test")
|
||||
}
|
||||
if c.MasterKey == "" {
|
||||
if c.Enabled || c.ActivationEnabled || c.SMSLoginEnabled {
|
||||
return errors.New("onboarding requires ONBOARDING_MASTER_KEY")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
key, err := hex.DecodeString(c.MasterKey)
|
||||
if err != nil || len(key) != 32 {
|
||||
return errors.New("ONBOARDING_MASTER_KEY must encode 32 bytes as hex")
|
||||
}
|
||||
u, err := url.Parse(c.EntryURL)
|
||||
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && !(c.AllowLocalHTTP && u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) {
|
||||
return errors.New("ONBOARDING_ENTRY_URL must be a fixed HTTPS URL")
|
||||
}
|
||||
for _, cidr := range c.TrustedProxyCIDRs {
|
||||
p, err := netip.ParsePrefix(cidr)
|
||||
if err != nil || p.Bits() == 0 {
|
||||
return errors.New("onboarding trusted proxies must be explicit non-global CIDRs")
|
||||
}
|
||||
}
|
||||
for _, id := range c.SelfSignupTenantIDs {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return errors.New("onboarding signup tenants must be UUIDs")
|
||||
}
|
||||
}
|
||||
if c.SMSLoginEnabled {
|
||||
u, err := url.Parse(c.SMSGatewayURL)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || c.SMSGatewayToken == "" {
|
||||
return errors.New("onboarding SMS requires HTTPS gateway and token")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -124,6 +124,13 @@ func Apply(ctx context.Context, tx pgx.Tx, input Input) (Result, error) {
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("parse stored device activation state: %w", err)
|
||||
}
|
||||
var enrollmentMode string
|
||||
if err := tx.QueryRow(ctx, `SELECT enrollment_mode FROM device WHERE id=$1::uuid`, input.DeviceID).Scan(&enrollmentMode); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if enrollmentMode == "physical_confirm" && input.Source != "onboarding" && (target == Active || target == Bound || target == Provisioning) {
|
||||
return Result{}, ErrTransitionRejected
|
||||
}
|
||||
if input.ExpectedRevision != nil && revision != *input.ExpectedRevision {
|
||||
return Result{}, ErrRevisionConflict
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ type contextKey string
|
||||
const identityKey contextKey = "device-identity"
|
||||
|
||||
type Identity struct {
|
||||
EnrollmentMode string
|
||||
BindingGeneration int64
|
||||
ActivationTest bool
|
||||
ID string
|
||||
PublicID string
|
||||
TenantID string
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package devicebinding owns compatibility gates shared by all ownership writers.
|
||||
// Generation advancement and claim fencing are enforced by the database trigger
|
||||
// so background deletion and operator mutations cannot omit invalidation.
|
||||
package devicebinding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrConfirmationRequired = errors.New("device physical confirmation workflow required")
|
||||
|
||||
type Queryer interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
func RequireLegacy(ctx context.Context, q Queryer, deviceID string) error {
|
||||
var mode string
|
||||
err := q.QueryRow(ctx, `SELECT enrollment_mode FROM device WHERE id=$1::uuid`, deviceID).Scan(&mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mode != "legacy" {
|
||||
return ErrConfirmationRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func RequireUnboundForReset(ctx context.Context, q Queryer, deviceID string) error {
|
||||
var allowed bool
|
||||
err := q.QueryRow(ctx, `SELECT enrollment_mode='legacy' OR NOT EXISTS(SELECT 1 FROM device_binding WHERE device_id=d.id AND unbound_at IS NULL) FROM device d WHERE id=$1::uuid`, deviceID).Scan(&allowed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed {
|
||||
return ErrConfirmationRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -48,6 +49,7 @@ type Claim struct {
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
BindingGeneration int64 `json:"binding_generation,omitempty"`
|
||||
ResetID string `json:"reset_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ActivationState string `json:"activation_state"`
|
||||
@@ -59,7 +61,7 @@ type Response struct {
|
||||
EndedConversationCount int `json:"ended_conversation_count"`
|
||||
CancelledCommandCount int64 `json:"cancelled_command_count"`
|
||||
ConfigRevision int64 `json:"config_revision"`
|
||||
Claim Claim `json:"claim"`
|
||||
Claim *Claim `json:"claim,omitempty"`
|
||||
LocalActions []string `json:"local_actions"`
|
||||
ResetAt time.Time `json:"reset_at"`
|
||||
}
|
||||
@@ -102,11 +104,40 @@ func (s *Service) Reset(ctx context.Context, identity deviceauth.Identity, idemp
|
||||
return Response{}, false, ErrPendingTranscripts
|
||||
}
|
||||
tenantID := identity.TenantID
|
||||
result, err := s.executor.Execute(ctx, idempotency.Request{
|
||||
if err := devicebinding.RequireUnboundForReset(ctx, s.pool, identity.ID); err != nil {
|
||||
return Response{}, false, err
|
||||
}
|
||||
guard := func(ctx context.Context, tx pgx.Tx, cached *idempotency.Result) error {
|
||||
var mode string
|
||||
var generation, counter int64
|
||||
if err := tx.QueryRow(ctx, `SELECT enrollment_mode,binding_generation,factory_reset_counter FROM device WHERE id=$1::uuid FOR UPDATE`, identity.ID).Scan(&mode, &generation, &counter); err != nil {
|
||||
return err
|
||||
}
|
||||
if mode != "physical_confirm" {
|
||||
return nil
|
||||
}
|
||||
if generation != identity.BindingGeneration {
|
||||
return ErrStateConflict
|
||||
}
|
||||
if err := devicebinding.RequireUnboundForReset(ctx, tx, identity.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
var previous Response
|
||||
if err := json.Unmarshal(cached.Body, &previous); err != nil {
|
||||
return err
|
||||
}
|
||||
if counter != input.ResetCounter || generation != previous.BindingGeneration {
|
||||
return ErrResetCounterConflict
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result, err := s.executor.ExecuteGuarded(ctx, idempotency.Request{
|
||||
TenantID: &tenantID, ActorKey: "device:" + identity.PublicID,
|
||||
Scope: "POST /device/v3/factory-reset", Key: idempotencyKey,
|
||||
RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 7 * 24 * time.Hour,
|
||||
}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
}, guard, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
response, resetErr := s.reset(operationContext, tx, identity, requestID, input)
|
||||
if resetErr != nil {
|
||||
return idempotency.Result{}, resetErr
|
||||
@@ -145,6 +176,9 @@ func (s *Service) reset(ctx context.Context, tx pgx.Tx, identity deviceauth.Iden
|
||||
if input.ResetCounter <= currentCounter {
|
||||
return Response{}, ErrResetCounterConflict
|
||||
}
|
||||
if err := devicebinding.RequireUnboundForReset(ctx, tx, identity.ID); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
var activeFirmware int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
@@ -233,10 +267,35 @@ func (s *Service) reset(ctx context.Context, tx pgx.Tx, identity deviceauth.Iden
|
||||
return Response{}, fmt.Errorf("record factory reset counter: %w", err)
|
||||
}
|
||||
|
||||
claim, err := issueClaim(ctx, tx, identity, now)
|
||||
if err != nil {
|
||||
var claim *Claim
|
||||
var mode string
|
||||
if err = tx.QueryRow(ctx, `SELECT enrollment_mode FROM device WHERE id=$1::uuid`, identity.ID).Scan(&mode); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if mode == "legacy" {
|
||||
value, e := issueClaim(ctx, tx, identity, now)
|
||||
if e != nil {
|
||||
return Response{}, e
|
||||
}
|
||||
claim = &value
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `UPDATE device SET binding_generation=binding_generation+1,onboarding_revision=onboarding_revision+1,onboarding_boot_id=NULL WHERE id=$1::uuid`, identity.ID)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='FACTORY_RESET' WHERE device_id=$1::uuid`, identity.ID)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, identity.ID)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE onboarding_web_session SET revoked_at=COALESCE(revoked_at,now()) WHERE setup_id IN (SELECT id FROM device_setup_session WHERE device_id=$1::uuid)`, identity.ID)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
response := Response{
|
||||
ResetID: input.ResetID, DeviceID: identity.PublicID, ActivationState: string(deviceactivation.Claimable),
|
||||
ResetCounter: input.ResetCounter, BindingRemoved: bindingRemoved, PreviousBindingID: previousBindingID,
|
||||
@@ -245,6 +304,11 @@ func (s *Service) reset(ctx context.Context, tx pgx.Tx, identity deviceauth.Iden
|
||||
ConfigRevision: configRevision, Claim: claim, ResetAt: now,
|
||||
LocalActions: []string{"clear_volc_credential", "clear_user_context", "clear_session_state", "clear_network_credentials", "reboot_to_claimable"},
|
||||
}
|
||||
if mode == "physical_confirm" {
|
||||
if err = tx.QueryRow(ctx, `SELECT binding_generation FROM device WHERE id=$1::uuid`, identity.ID).Scan(&response.BindingGeneration); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
actorID := identity.PublicID
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{
|
||||
TenantID: &identity.TenantID, ActorType: "device", ActorPublicID: &actorID,
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -140,6 +141,9 @@ func (s *Service) Provision(ctx context.Context, identity deviceauth.Identity, r
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, identity.ID); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("lock hardware provider provisioning: %w", err)
|
||||
}
|
||||
if err := devicebinding.RequireLegacy(ctx, tx, identity.ID); err != nil {
|
||||
return ProvisionResponse{}, err
|
||||
}
|
||||
activationState, err := s.prepareActivation(ctx, tx, identity, requestID)
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, err
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestAlertHandlersWriteAndReadLifecycle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
tenantID := "10000000-0000-0000-0000-000000000010"
|
||||
routeKey := "http-alert-route-" + uuid.NewString()
|
||||
deliveryTestKey := "http-alert-delivery-test-" + uuid.NewString()
|
||||
|
||||
@@ -35,8 +35,8 @@ func TestBillingHandlersPriceQuotaAndSummary(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
|
||||
tenantID := "10000000-0000-0000-0000-000000000010"
|
||||
priceKey, quotaKey := "http-billing-price-"+uuid.NewString(), "http-billing-quota-"+uuid.NewString()
|
||||
sourceRef := "integration://http-billing/" + uuid.NewString()
|
||||
|
||||
@@ -488,6 +488,8 @@ func writeCreateConversationError(writer http.ResponseWriter, request *http.Requ
|
||||
})
|
||||
case errors.Is(err, orchestration.ErrDeviceNotBound):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_NOT_BOUND", "设备尚未绑定有效长者")
|
||||
case errors.Is(err,orchestration.ErrOnboardingState):
|
||||
WriteError(writer,request,http.StatusConflict,"ONBOARDING_STATE_CONFLICT","绑定状态或首通许可已变化,请同步当前设备状态")
|
||||
case errors.Is(err, orchestration.ErrConsentRequired):
|
||||
WriteError(writer, request, http.StatusForbidden, "CONSENT_REQUIRED", "缺少本次语音通话所需授权")
|
||||
case errors.Is(err, orchestration.ErrFirmwareUpdateRequired):
|
||||
|
||||
@@ -47,7 +47,7 @@ func RequireDeviceActivation(allowed []string, next http.Handler) http.Handler {
|
||||
if state == "" {
|
||||
state = "ACTIVE"
|
||||
}
|
||||
if _, ok := allowedSet[state]; !ok {
|
||||
if _, ok := allowedSet[state]; !ok && !(identity.EnrollmentMode == "physical_confirm" && identity.ActivationTest) {
|
||||
WriteAPIError(w, r, http.StatusConflict, Error{Code: "DEVICE_ACTIVATION_REQUIRED", Message: "当前设备激活状态不能执行该操作", Details: map[string]any{"activation_state": state, "allowed_states": allowed}})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/devicereset"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
)
|
||||
@@ -50,6 +51,8 @@ func DeviceFactoryResetHandler(service DeviceFactoryResetService) http.Handler {
|
||||
}
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, devicebinding.ErrConfirmationRequired):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_AUTHORIZATION_REQUIRED", "请先由当前家庭授权解绑,随后再执行设备重置")
|
||||
case errors.Is(err, devicereset.ErrPendingTranscripts):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_PENDING_TRANSCRIPTS", "设备仍有未上传的聊天记录")
|
||||
case errors.Is(err, devicereset.ErrFirmwareInProgress):
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestDeviceFactoryResetHandlerReturnsClaimableState(t *testing.T) {
|
||||
ResetID: "10000000-0000-0000-0000-000000000901", DeviceID: "10000000-0000-0000-0000-000000000512",
|
||||
ActivationState: "CLAIMABLE", ResetCounter: 2, BindingRemoved: true,
|
||||
ProviderCredentialStatus: "SUSPENDED", LicenseAction: "RETAINED",
|
||||
Claim: devicereset.Claim{Code: "ABCD-EFGH-IJKL-MNOP", IssuedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(24 * time.Hour)},
|
||||
Claim: &devicereset.Claim{Code: "ABCD-EFGH-IJKL-MNOP", IssuedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(24 * time.Hour)},
|
||||
}}
|
||||
body := validDeviceFactoryResetBody()
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/factory-reset", strings.NewReader(body))
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestDiagnosticHandlersSearchRefreshJobsAndBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
tenantID := "10000000-0000-0000-0000-000000000010"
|
||||
deviceID := "10000000-0000-0000-0000-000000000511"
|
||||
refreshKey := "http-diagnostic-refresh-" + uuid.NewString()
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/hardwarelicense"
|
||||
"data-terminal-cloud/internal/hardwareprovision"
|
||||
"data-terminal-cloud/internal/orchestration"
|
||||
@@ -88,6 +89,8 @@ func ProvisionHardwareAgentCredentialHandler(service HardwareAgentProvisioningSe
|
||||
switch {
|
||||
case errors.Is(err, hardwareprovision.ErrUnavailable):
|
||||
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "HARDWARE_AGENT_PROVISIONING_UNAVAILABLE", Message: "平台尚未配置火山设备注册服务", Retryable: false})
|
||||
case errors.Is(err, devicebinding.ErrConfirmationRequired):
|
||||
WriteError(writer, request, http.StatusConflict, "ONBOARDING_ACTIVATION_REQUIRED", "请通过首次接入激活任务获取运行配置")
|
||||
case errors.Is(err, hardwareprovision.ErrInvalid):
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "火山硬件标识不正确")
|
||||
case errors.Is(err, hardwareprovision.ErrBindingMissing):
|
||||
@@ -187,7 +190,7 @@ func HardwareAgentEventHandler(service HardwareAgentSessionService) http.Handler
|
||||
return
|
||||
}
|
||||
switch input.EventType {
|
||||
case "session_started", "session_ended", "session_failed", "session_updated":
|
||||
case "session_started", "session_ended", "session_failed", "session_updated", "first_audio_received":
|
||||
default:
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "不支持的硬件智能体事件类型")
|
||||
return
|
||||
@@ -204,7 +207,7 @@ func HardwareAgentEventHandler(service HardwareAgentSessionService) http.Handler
|
||||
WriteError(writer, request, http.StatusNotFound, "CONVERSATION_NOT_FOUND", "通话不存在或不属于当前设备")
|
||||
case errors.Is(err, orchestration.ErrHardwareSessionProof):
|
||||
WriteError(writer, request, http.StatusUnauthorized, "HARDWARE_SESSION_PROOF_INVALID", "硬件智能体会话凭证无效")
|
||||
case errors.Is(err, orchestration.ErrHardwareEventConflict), errors.Is(err, orchestration.ErrConversationStateConflict):
|
||||
case errors.Is(err, orchestration.ErrHardwareEventConflict), errors.Is(err, orchestration.ErrConversationStateConflict), errors.Is(err,orchestration.ErrOnboardingState):
|
||||
WriteError(writer, request, http.StatusConflict, "HARDWARE_AGENT_EVENT_CONFLICT", "硬件智能体事件与当前会话状态冲突")
|
||||
default:
|
||||
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "HARDWARE_AGENT_EVENT_UNAVAILABLE", Message: "暂时无法处理硬件智能体事件", Retryable: true})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"data-terminal-cloud/internal/onboarding"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func onboardingCustomerAuth(service *onboarding.Service, bearer, next http.Handler) http.Handler {
|
||||
if service == nil {
|
||||
return bearer
|
||||
}
|
||||
cookie := service.CustomerAuth(next)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
allowed := path == "/api/v1/me" || path == "/api/v1/households" || strings.HasPrefix(path, "/api/v1/households/") || strings.HasPrefix(path, "/api/v1/subjects/") || strings.HasPrefix(path, "/api/v1/devices/")
|
||||
if r.Header.Get("Authorization") == "" && allowed {
|
||||
cookie.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
bearer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -42,8 +42,8 @@ func TestOpsAccessHandlersCompleteTwoPersonAccessFlow(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requester := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
approver := identity.Principal{Issuer: requester.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
|
||||
requester := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
approver := identity.Principal{Issuer: requester.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
|
||||
tenantPublicID, tenantID := "10000000-0000-0000-0000-000000000010", "10000000-0000-0000-0000-000000000001"
|
||||
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
|
||||
consentSnapshotID, contextSnapshotID := uuid.NewString(), uuid.NewString()
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestOpsConfigHandlersPublishAndRollback(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
|
||||
tenantID := "10000000-0000-0000-0000-000000000010"
|
||||
configKey := "http.integration.routing." + uuid.NewString()
|
||||
createKey, evaluationKey, rollbackKey := "http-ops-create-"+uuid.NewString(), "http-ops-eval-"+uuid.NewString(), "http-ops-rollback-"+uuid.NewString()
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/concurrency"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/opsdevice"
|
||||
)
|
||||
@@ -268,6 +269,8 @@ func opsDevicePrincipal(w http.ResponseWriter, r *http.Request) (principal ident
|
||||
|
||||
func writeOpsDeviceError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, devicebinding.ErrConfirmationRequired):
|
||||
WriteError(w, r, http.StatusConflict, "DEVICE_CONFIRMATION_REQUIRED", "该设备使用生产凭据和按键确认流程,请使用首次接入管理入口")
|
||||
case errors.Is(err, opsdevice.ErrInvalid):
|
||||
writeValidation(w, r, "设备管理参数不正确", FieldError{Field: "device", Message: "设备字段或家庭/长者归属不正确"})
|
||||
case errors.Is(err, opsdevice.ErrAccessDenied):
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestOpsGateHandlersResourceReferencesAndDeviceGroups(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
tenantID := "10000000-0000-0000-0000-000000000010"
|
||||
deviceID := "10000000-0000-0000-0000-000000000511"
|
||||
resourceKey := "http-ops-gates-resource-" + uuid.NewString()
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/media"
|
||||
"data-terminal-cloud/internal/memorytool"
|
||||
"data-terminal-cloud/internal/onboarding"
|
||||
"data-terminal-cloud/internal/opsaccess"
|
||||
"data-terminal-cloud/internal/opsconfig"
|
||||
"data-terminal-cloud/internal/opsdevice"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
)
|
||||
|
||||
type RouterOptions struct {
|
||||
OnboardingService *onboarding.Service
|
||||
ServiceName string
|
||||
Logger *slog.Logger
|
||||
RateLimiter RateLimiter
|
||||
@@ -71,6 +73,9 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
logger = slog.Default()
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
if options.ServiceName == "api" && options.OnboardingService != nil {
|
||||
options.OnboardingService.RegisterRoutes(mux)
|
||||
}
|
||||
mux.Handle("GET /metrics", MetricsHandler())
|
||||
mux.HandleFunc("GET /health/live", func(writer http.ResponseWriter, request *http.Request) {
|
||||
WriteJSON(writer, request, http.StatusOK, map[string]string{
|
||||
@@ -93,11 +98,13 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("POST /internal/v1/tools/memory-search", MemoryToolSearchHandler(options.MemoryToolService, options.MemoryToolSigner, options.RateLimiter, logger))
|
||||
}
|
||||
if options.ServiceName == "api" && options.TokenVerifier != nil && options.IdentityRepository != nil {
|
||||
mux.Handle("GET /api/v1/me", BearerAuth(options.TokenVerifier, UserRateLimit(logger, options.RateLimiter, CurrentUserHandler(options.IdentityRepository))))
|
||||
handler := UserRateLimit(logger, options.RateLimiter, CurrentUserHandler(options.IdentityRepository))
|
||||
mux.Handle("GET /api/v1/me", onboardingCustomerAuth(options.OnboardingService, BearerAuth(options.TokenVerifier, handler), handler))
|
||||
}
|
||||
if options.ServiceName == "api" && options.TokenVerifier != nil && options.SaaSService != nil {
|
||||
authenticated := func(handler http.Handler) http.Handler {
|
||||
return BearerAuth(options.TokenVerifier, UserRateLimit(logger, options.RateLimiter, handler))
|
||||
limited := UserRateLimit(logger, options.RateLimiter, handler)
|
||||
return onboardingCustomerAuth(options.OnboardingService, BearerAuth(options.TokenVerifier, limited), limited)
|
||||
}
|
||||
if options.WebEventService != nil {
|
||||
mux.Handle("GET /api/v1/events", authenticated(StreamWebEventsHandler(options.WebEventService)))
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/concurrency"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/pagination"
|
||||
"data-terminal-cloud/internal/saas"
|
||||
@@ -1882,6 +1883,8 @@ func writeETagError(writer http.ResponseWriter, request *http.Request, err error
|
||||
|
||||
func writeSaaSError(writer http.ResponseWriter, request *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, devicebinding.ErrConfirmationRequired):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_CONFIRMATION_REQUIRED", "请先授权解绑,再从设备设置入口申请并按键确认绑定")
|
||||
case errors.Is(err, saas.ErrAccessDenied):
|
||||
WriteError(writer, request, http.StatusForbidden, "RESOURCE_ACCESS_DENIED", "当前账号无权访问该资源")
|
||||
case errors.Is(err, saas.ErrNotFound):
|
||||
|
||||
@@ -81,6 +81,14 @@ func (e *Executor) Execute(
|
||||
request Request,
|
||||
operation func(context.Context, pgx.Tx) (Result, error),
|
||||
) (Result, error) {
|
||||
return e.ExecuteGuarded(ctx, request, nil, operation)
|
||||
}
|
||||
|
||||
// Guard executes in the operation transaction before mutation or replay. On
|
||||
// replay it also receives the stored resource for current authorization checks.
|
||||
type Guard func(context.Context, pgx.Tx, *Result) error
|
||||
|
||||
func (e *Executor) ExecuteGuarded(ctx context.Context, request Request, guard Guard, operation func(context.Context, pgx.Tx) (Result, error)) (Result, error) {
|
||||
if err := validateRequest(request); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
@@ -89,6 +97,11 @@ func (e *Executor) Execute(
|
||||
return Result{}, fmt.Errorf("begin idempotency transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
if guard != nil {
|
||||
if err := guard(ctx, tx, nil); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
INSERT INTO idempotency_record (
|
||||
@@ -103,7 +116,7 @@ func (e *Executor) Execute(
|
||||
return Result{}, fmt.Errorf("claim idempotency key: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return e.replay(ctx, tx, request)
|
||||
return e.replay(ctx, tx, request, guard)
|
||||
}
|
||||
|
||||
result, err := operation(ctx, tx)
|
||||
@@ -129,7 +142,7 @@ func (e *Executor) Execute(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *Executor) replay(ctx context.Context, tx pgx.Tx, request Request) (Result, error) {
|
||||
func (e *Executor) replay(ctx context.Context, tx pgx.Tx, request Request, guard Guard) (Result, error) {
|
||||
var storedHash, state string
|
||||
var result Result
|
||||
err := tx.QueryRow(ctx, `
|
||||
@@ -148,6 +161,11 @@ func (e *Executor) replay(ctx context.Context, tx pgx.Tx, request Request) (Resu
|
||||
return Result{}, ErrInProgress
|
||||
}
|
||||
result.Replayed = true
|
||||
if guard != nil {
|
||||
if err := guard(ctx, tx, &result); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Result{}, fmt.Errorf("commit idempotency replay: %w", err)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
// operational alerting. It is registered by the single worker process so
|
||||
// horizontally scaled API instances do not publish duplicate database gauges.
|
||||
func RegisterBusinessMetrics(pool *pgxpool.Pool) {
|
||||
registerStatusCounts(pool, "onboarding_activation_jobs", "Physical device activation jobs by durable status.", "device_activation_job", []string{"NOT_STARTED", "RESERVED", "CONTACT_POSSIBLE", "REGISTERED_OBSERVED", "READY", "RECONCILE_REQUIRED", "BLOCKED"})
|
||||
registerStatusCounts(pool, "onboarding_claims", "Physical confirmation claims by status.", "device_binding_claim", []string{"PENDING", "CONFIRMED", "CANCELLED", "EXPIRED"})
|
||||
registerStatusCounts(pool, "conversations", "Current conversations by lifecycle status.", "conversation", []string{"PREPARING", "ROOM_ISSUED", "STARTING_AGENT", "ACTIVE", "STOPPING", "COMPLETED", "FAILED", "EXPIRED"})
|
||||
registerStatusCounts(pool, "privacy_requests", "Current privacy requests by status.", "privacy_request", []string{"SUBMITTED", "VERIFYING", "EXECUTING", "COMPLETED", "PARTIAL_FAILED"})
|
||||
registerStatusCounts(pool, "firmware_rollouts", "Current firmware rollouts by status.", "firmware_rollout", []string{"DRAFT", "RUNNING", "PAUSED", "COMPLETED", "ROLLED_BACK"})
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const ActivationEvent = "device.onboarding.activation.requested"
|
||||
|
||||
type activationJob struct {
|
||||
ID, DeviceID, Status, Name, Instance, Product, Bot, Transport, MinimumSDK, RTCAppID string
|
||||
Generation, ConfigVersion, Version int64
|
||||
Secret, Config []byte
|
||||
Digest, AttemptID string
|
||||
Expires, Ack, Engine, Registered *time.Time
|
||||
ReadyGeneration *int64
|
||||
}
|
||||
|
||||
func readJob(ctx context.Context, tx pgx.Tx, device string) (activationJob, error) {
|
||||
var j activationJob
|
||||
err := tx.QueryRow(ctx, `SELECT id::text,device_id::text,status,provider_device_name,binding_generation,config_version,version,COALESCE(provider_instance_id,''),COALESCE(provider_product_key,''),COALESCE(provider_bot_id,''),COALESCE(provider_transport,''),COALESCE(provider_minimum_sdk,''),COALESCE(provider_rtc_app_id,''),provider_secret_ciphertext,config_ciphertext,COALESCE(config_digest,''),COALESCE(current_attempt_id::text,''),config_expires_at,config_ack_at,engine_ready_at,ready_binding_generation,sdk_registered_at FROM device_activation_job WHERE device_id=$1::uuid FOR UPDATE`, device).Scan(&j.ID, &j.DeviceID, &j.Status, &j.Name, &j.Generation, &j.ConfigVersion, &j.Version, &j.Instance, &j.Product, &j.Bot, &j.Transport, &j.MinimumSDK, &j.RTCAppID, &j.Secret, &j.Config, &j.Digest, &j.AttemptID, &j.Expires, &j.Ack, &j.Engine, &j.ReadyGeneration, &j.Registered)
|
||||
return j, err
|
||||
}
|
||||
func (s *Service) enqueueActivation(ctx context.Context, tx pgx.Tx, d Device, generation int64, requestID string) error {
|
||||
_, err := tx.Exec(ctx, `INSERT INTO device_activation_job(device_id,tenant_id,provider_device_name,binding_generation,provider_instance_id,provider_product_key,provider_bot_id,provider_transport,provider_minimum_sdk) VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(device_id) DO NOTHING`, d.ID, d.TenantID, d.FactoryID, generation, s.options.InstanceID, s.options.ProductKey, s.options.BotID, s.options.Transport, s.options.MinimumSDKVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
j, err := readJob(ctx, tx, d.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if j.Generation != generation {
|
||||
status := "NOT_STARTED"
|
||||
if j.Status == "RECONCILE_REQUIRED" || j.Status == "CONTACT_POSSIBLE" {
|
||||
status = "RECONCILE_REQUIRED"
|
||||
} else if j.Registered != nil {
|
||||
status = "REGISTERED_OBSERVED"
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET binding_generation=$2,config_version=config_version+1,config_ciphertext=NULL,config_digest=NULL,config_expires_at=NULL,config_ack_at=NULL,engine_ready_at=NULL,status=$3,current_attempt_id=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, generation, status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='DEFINITE_FAILURE',completed_at=now(),evidence='{"reason":"BINDING_CHANGED"}' WHERE activation_id=$1::uuid AND executor='DEVICE' AND status IN ('CONTACT_POSSIBLE','UNKNOWN')`, j.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = bump(ctx, tx, d.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
dedup := fmt.Sprintf("%s:%d", d.ID, generation)
|
||||
payload, _ := json.Marshal(map[string]any{"device_id": d.ID, "activation_id": j.ID, "binding_generation": generation})
|
||||
_, err = s.outbox.Enqueue(ctx, tx, outbox.NewEvent{TenantID: &d.TenantID, AggregateType: "device", AggregateID: d.ID, EventType: ActivationEvent, DeduplicationKey: &dedup, Payload: payload})
|
||||
return err
|
||||
}
|
||||
|
||||
// Keep the established credential encryption format for existing subtitle and
|
||||
// session services. The onboarding job remains the authority for READY.
|
||||
func (s *Service) storeRuntimeCredential(ctx context.Context, tx pgx.Tx, d Device, j activationJob, secret []byte) error {
|
||||
if s.providerCrypt.aead == nil {
|
||||
return errors.New("provider credential master key unavailable")
|
||||
}
|
||||
ref := "volc-hardware://" + d.ID
|
||||
aad := fmt.Sprintf("hardware-provider-credential-v1\n%s\n%d\n%s\n%s\n%s", ref, 1, j.Instance, j.Product, j.Name)
|
||||
encrypted := s.providerCrypt.seal(secret, aad)
|
||||
n := s.providerCrypt.aead.NonceSize()
|
||||
_, err := tx.Exec(ctx, `INSERT INTO hardware_agent_provider_credential(tenant_id,device_id,credential_ref,instance_id,product_key,provider_device_name,hardware_id,encrypted_secret,nonce) VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(device_id) DO UPDATE SET encrypted_secret=EXCLUDED.encrypted_secret,nonce=EXCLUDED.nonce,status='ACTIVE',updated_at=now() WHERE hardware_agent_provider_credential.credential_ref=EXCLUDED.credential_ref AND hardware_agent_provider_credential.provider_device_name=EXCLUDED.provider_device_name`, d.TenantID, d.ID, ref, j.Instance, j.Product, j.Name, d.FactoryID, encrypted[n:], encrypted[:n])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO hardware_agent_device_binding(tenant_id,device_id,provider,product_key,bot_id,transport,minimum_sdk_version,status) VALUES($1::uuid,$2::uuid,'volcengine',$3,$4,$5,$6,'ACTIVE') ON CONFLICT(device_id) DO UPDATE SET product_key=EXCLUDED.product_key,bot_id=EXCLUDED.bot_id,transport=EXCLUDED.transport,minimum_sdk_version=EXCLUDED.minimum_sdk_version,status='ACTIVE',updated_at=now()`, d.TenantID, d.ID, j.Product, j.Bot, j.Transport, j.MinimumSDK)
|
||||
return err
|
||||
}
|
||||
func sameSecret(a, b string) bool { return hmac.Equal([]byte(a), []byte(b)) }
|
||||
@@ -0,0 +1,184 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var factoryPattern = regexp.MustCompile(`^KP01-[0-9A-F]{12}$`)
|
||||
|
||||
type deviceContextKey struct{}
|
||||
|
||||
func DeviceFromContext(ctx context.Context) (Device, bool) {
|
||||
d, ok := ctx.Value(deviceContextKey{}).(Device)
|
||||
return d, ok
|
||||
}
|
||||
|
||||
func (s *Service) authenticate(r *http.Request) (Device, []byte, error) {
|
||||
id := r.Header.Get("X-Device-ID")
|
||||
nonce := r.Header.Get("X-Nonce")
|
||||
requestID := r.Header.Get("X-Request-ID")
|
||||
version, ve := strconv.ParseInt(r.Header.Get("X-Key-Version"), 10, 64)
|
||||
timestamp, te := strconv.ParseInt(r.Header.Get("X-Timestamp-Ms"), 10, 64)
|
||||
if strconv.FormatInt(version, 10) != r.Header.Get("X-Key-Version") || strconv.FormatInt(timestamp, 10) != r.Header.Get("X-Timestamp-Ms") {
|
||||
return Device{}, nil, errAuth
|
||||
}
|
||||
signature := strings.TrimPrefix(r.Header.Get("Authorization"), "Device-HMAC-SHA256 ")
|
||||
if !factoryPattern.MatchString(id) || ve != nil || te != nil || version < 1 || !validB64(nonce, 16) || !uuidV4(requestID) || !validDigest(signature) || signature == r.Header.Get("Authorization") || r.URL.RawQuery != "" || strings.ContainsAny(r.URL.EscapedPath(), "\r\n") {
|
||||
return Device{}, nil, errAuth
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
|
||||
if err != nil || len(raw) > maxBody {
|
||||
return Device{}, nil, fail(413, "REQUEST_TOO_LARGE", "请求正文过长")
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
d, err := s.findFactoryDevice(r.Context(), id, version)
|
||||
if err != nil {
|
||||
return Device{}, nil, err
|
||||
}
|
||||
expected := RequestMAC(d.Key, r.Method, r.URL.EscapedPath(), id, version, timestamp, nonce, requestID, raw)
|
||||
if !hmac.Equal([]byte(expected), []byte(signature)) || time.UnixMilli(timestamp).Before(s.now().Add(-120*time.Second)) || time.UnixMilli(timestamp).After(s.now().Add(120*time.Second)) {
|
||||
return Device{}, nil, errAuth
|
||||
}
|
||||
fresh, err := s.redis.SetNX(r.Context(), "onboarding:nonce:"+id+":"+strconv.FormatInt(version, 10)+":"+nonce, "1", 5*time.Minute).Result()
|
||||
if err != nil {
|
||||
return Device{}, nil, fail(503, "AUTHENTICATION_UNAVAILABLE", "认证服务暂不可用")
|
||||
}
|
||||
if !fresh {
|
||||
return Device{}, nil, errAuth
|
||||
}
|
||||
if !deviceAllowed(d) {
|
||||
return Device{}, nil, errAuth
|
||||
}
|
||||
return d, raw, nil
|
||||
}
|
||||
func (s *Service) findFactoryDevice(ctx context.Context, factoryID string, version int64) (Device, error) {
|
||||
var id string
|
||||
err := s.pool.QueryRow(ctx, `SELECT id::text FROM device WHERE factory_device_id=$1 AND enrollment_mode='physical_confirm'`, factoryID).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Device{}, errAuth
|
||||
}
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
d, err := readDevice(ctx, s.pool, id, false)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
var ciphertext []byte
|
||||
err = s.pool.QueryRow(ctx, `SELECT ciphertext FROM device_onboarding_key WHERE device_id=$1::uuid AND key_version=$2 AND status='ACTIVE'`, id, version).Scan(&ciphertext)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return d, errAuth
|
||||
}
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
d.Key, err = s.crypt.open(ciphertext, keyAAD(id, version))
|
||||
if err != nil || len(d.Key) != 32 {
|
||||
return d, errAuth
|
||||
}
|
||||
d.KeyVersion = version
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Verify adapts explicitly enrolled devices for the existing voice interfaces.
|
||||
// A failed signature is never retried with the other signing algorithm.
|
||||
func (s *Service) Verify(r *http.Request) (deviceauth.Identity, error) {
|
||||
id := r.Header.Get("X-Device-ID")
|
||||
if factoryPattern.MatchString(id) {
|
||||
if !s.secure(r) {
|
||||
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
|
||||
}
|
||||
d, raw, err := s.authenticate(r)
|
||||
if err != nil {
|
||||
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
var body map[string]any
|
||||
if err = StrictJSON(raw, &body); err != nil {
|
||||
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
|
||||
}
|
||||
}
|
||||
test, err := s.runtimeAdmission(r, d, raw)
|
||||
if err != nil {
|
||||
return deviceauth.Identity{}, err
|
||||
}
|
||||
return deviceauth.Identity{ID: d.ID, PublicID: d.PublicID, TenantID: d.TenantID, Status: d.Status, ActivationState: d.Lifecycle, VoiceRuntime: "volc_hardware_agent", CredentialRef: "onboarding://" + d.FactoryID, EnrollmentMode: d.Mode, BindingGeneration: d.Generation, ActivationTest: test}, nil
|
||||
}
|
||||
if s.options.LegacyVerifier == nil {
|
||||
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
|
||||
}
|
||||
legacy, err := s.options.LegacyVerifier.Verify(r)
|
||||
if err != nil {
|
||||
return legacy, err
|
||||
}
|
||||
var mode string
|
||||
if err = s.pool.QueryRow(r.Context(), `SELECT enrollment_mode FROM device WHERE id=$1::uuid`, legacy.ID).Scan(&mode); err != nil {
|
||||
return legacy, err
|
||||
}
|
||||
if mode != "legacy" {
|
||||
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
|
||||
}
|
||||
return legacy, nil
|
||||
}
|
||||
|
||||
type TimeChallengeInput struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
KeyVersion int64 `json:"key_version"`
|
||||
BootID string `json:"boot_id"`
|
||||
ClientNonce string `json:"client_nonce"`
|
||||
}
|
||||
|
||||
func (s *Service) TimeChallenge(ctx context.Context, input TimeChallengeInput) (map[string]any, error) {
|
||||
if !factoryPattern.MatchString(input.DeviceID) || input.KeyVersion < 1 || !uuidV4(input.BootID) || !validB64(input.ClientNonce, 16) {
|
||||
return nil, errAuth
|
||||
}
|
||||
d, err := s.findFactoryDevice(ctx, input.DeviceID, input.KeyVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !deviceAllowed(d) {
|
||||
return nil, errAuth
|
||||
}
|
||||
if err := s.limit(ctx, "clock:"+d.ID, 30, time.Minute); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var challenge string
|
||||
var timestamp int64
|
||||
var expires time.Time
|
||||
err = s.pool.QueryRow(ctx, `INSERT INTO device_time_challenge(device_id,key_version,boot_id,client_nonce,server_time_ms,expires_at) VALUES($1::uuid,$2,$3::uuid,$4,$5,$6) ON CONFLICT(device_id,key_version,boot_id,client_nonce) DO UPDATE SET client_nonce=EXCLUDED.client_nonce RETURNING id::text,server_time_ms,expires_at`, d.ID, d.KeyVersion, input.BootID, input.ClientNonce, s.now().UnixMilli(), s.now().Add(120*time.Second)).Scan(&challenge, ×tamp, &expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !expires.After(s.now()) {
|
||||
return nil, fail(410, "CHALLENGE_EXPIRED", "请使用新的校时随机数")
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"device_id": d.FactoryID, "boot_id": input.BootID, "client_nonce": input.ClientNonce, "challenge_id": challenge, "server_time_ms": timestamp, "expires_at_ms": expires.UnixMilli()})
|
||||
encoded := encode64(raw)
|
||||
return map[string]any{"payload_b64": encoded, "mac_hex": domainMAC(d.Key, "TIME1\n"+encoded)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) limit(ctx context.Context, key string, max int64, window time.Duration) error {
|
||||
// Atomic expiry: a process failure between INCR and EXPIRE must not leave
|
||||
// a permanently exhausted rate-limit key.
|
||||
n, err := s.redis.Eval(ctx, `local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('PEXPIRE',KEYS[1],ARGV[1]); end; return n`, []string{"onboarding:limit:" + key}, window.Milliseconds()).Int64()
|
||||
if err != nil {
|
||||
return fail(503, "RATE_LIMIT_UNAVAILABLE", "访问控制暂不可用")
|
||||
}
|
||||
if n > max {
|
||||
return &Error{Status: 429, Code: "RATE_LIMITED", Message: "请求过于频繁,请稍后重试", Recovery: "RETRY"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) stateAckCanClose(ctx context.Context, tx pgx.Tx, d Device) (bool, error) {
|
||||
var setupID string
|
||||
err := tx.QueryRow(ctx, `SELECT id::text FROM device_setup_session WHERE device_id=$1::uuid AND boot_id=$2::uuid AND closed_at IS NULL ORDER BY created_at DESC LIMIT 1`, d.ID, d.BootID).Scan(&setupID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, allowed, err := s.closeDelay(ctx, tx, d, setupID)
|
||||
return allowed, err
|
||||
}
|
||||
|
||||
func (s *Service) closeDelay(ctx context.Context, tx pgx.Tx, d Device, setupID string) (int64, bool, error) {
|
||||
var saved time.Time
|
||||
var web *time.Time
|
||||
err := tx.QueryRow(ctx, `SELECT a.accepted_at,CASE WHEN ss.web_ack_binding_id=b.id AND ss.web_ack_generation=b.generation THEN ss.web_ack_at END FROM device_state_ack a JOIN device_binding b ON b.device_id=a.device_id AND b.generation=a.binding_generation AND b.unbound_at IS NULL JOIN device_setup_session ss ON ss.device_id=b.device_id AND ss.id=$4::uuid WHERE a.device_id=$1::uuid AND a.binding_generation=$2 AND a.boot_id=$3::uuid`, d.ID, d.Generation, d.BootID, setupID).Scan(&saved, &web)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 30000, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
deadline := saved.Add(30 * time.Second)
|
||||
if web != nil {
|
||||
both := saved
|
||||
if web.After(both) {
|
||||
both = *web
|
||||
}
|
||||
if short := both.Add(3 * time.Second); short.Before(deadline) {
|
||||
deadline = short
|
||||
}
|
||||
}
|
||||
delay := deadline.Sub(s.now()).Milliseconds()
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
return delay, delay == 0, nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const ContractVersion = "ONBOARDING-1.1"
|
||||
const maxBody = 8 << 10
|
||||
|
||||
func digest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) }
|
||||
func randomToken(n int) string { return base64.RawURLEncoding.EncodeToString(randomBytes(n)) }
|
||||
func randomBytes(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("secure random source unavailable")
|
||||
}
|
||||
return b
|
||||
}
|
||||
func uuidV4(s string) bool {
|
||||
u, e := uuid.Parse(s)
|
||||
return e == nil && u.Version() == 4 && u.String() == s
|
||||
}
|
||||
func uuidValid(s string) bool { u, e := uuid.Parse(s); return e == nil && u.String() == s }
|
||||
func validB64(s string, n int) bool {
|
||||
b, e := base64.RawURLEncoding.DecodeString(s)
|
||||
return e == nil && len(b) == n && base64.RawURLEncoding.EncodeToString(b) == s
|
||||
}
|
||||
func validDigest(s string) bool {
|
||||
b, e := hex.DecodeString(s)
|
||||
return e == nil && len(b) == 32 && strings.ToLower(s) == s
|
||||
}
|
||||
|
||||
func RequestMAC(key []byte, method, path, deviceID string, version, timestamp int64, nonce, requestID string, body []byte) string {
|
||||
canonical := strings.Join([]string{"DEVREQ1", strings.ToUpper(method), path, deviceID, strconv.FormatInt(version, 10), strconv.FormatInt(timestamp, 10), nonce, requestID, digest(body)}, "\n")
|
||||
return domainMAC(key, canonical)
|
||||
}
|
||||
func domainMAC(key []byte, message string) string {
|
||||
m := hmac.New(sha256.New, key)
|
||||
m.Write([]byte(message))
|
||||
return hex.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
type cryptor struct {
|
||||
aead cipher.AEAD
|
||||
hashKey []byte
|
||||
}
|
||||
|
||||
func newCryptor(master string) (cryptor, error) {
|
||||
key, err := hex.DecodeString(master)
|
||||
if err != nil || len(key) != 32 {
|
||||
return cryptor{}, errors.New("onboarding master key must be 32 bytes encoded as hex")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return cryptor{}, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
derived := sha256.Sum256(append([]byte("ONBOARDING-HASH-KEY1:"), key...))
|
||||
return cryptor{aead: aead, hashKey: derived[:]}, err
|
||||
}
|
||||
func (c cryptor) seal(plain []byte, aad string) []byte {
|
||||
nonce := randomBytes(c.aead.NonceSize())
|
||||
return c.aead.Seal(nonce, nonce, plain, []byte(aad))
|
||||
}
|
||||
func (c cryptor) open(value []byte, aad string) ([]byte, error) {
|
||||
n := c.aead.NonceSize()
|
||||
if len(value) < n {
|
||||
return nil, errors.New("invalid encrypted onboarding record")
|
||||
}
|
||||
return c.aead.Open(nil, value[:n], value[n:], []byte(aad))
|
||||
}
|
||||
func keyAAD(device string, version int64) string {
|
||||
return fmt.Sprintf("ONBOARDING-KEY1\n%s\n%d", device, version)
|
||||
}
|
||||
|
||||
// Validate JSON before decoding structs: Go's regular decoder accepts duplicate
|
||||
// keys and repairs invalid UTF-8, both of which are forbidden by this contract.
|
||||
func StrictJSON(raw []byte, target any) error {
|
||||
if len(raw) == 0 || len(raw) > maxBody || !utf8.Valid(raw) {
|
||||
return fail(400, "INVALID_JSON", "请求正文不合法")
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var visit func(int) error
|
||||
visit = func(depth int) error {
|
||||
if depth > 12 {
|
||||
return errors.New("JSON nesting exceeds 12")
|
||||
}
|
||||
token, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delim, ok := token.(json.Delim)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch delim {
|
||||
case '{':
|
||||
seen := map[string]bool{}
|
||||
for dec.More() {
|
||||
k, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, ok := k.(string)
|
||||
if !ok || seen[name] {
|
||||
return errors.New("duplicate JSON key")
|
||||
}
|
||||
seen[name] = true
|
||||
if err := visit(depth + 1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case '[':
|
||||
for dec.More() {
|
||||
if err := visit(depth + 1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return errors.New("invalid JSON delimiter")
|
||||
}
|
||||
_, err = dec.Token()
|
||||
return err
|
||||
}
|
||||
if err := visit(1); err != nil {
|
||||
return fail(400, "INVALID_JSON", "JSON格式、深度或字段重复")
|
||||
}
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
return fail(400, "INVALID_JSON", "只能提交一个JSON对象")
|
||||
}
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 || trimmed[0] != '{' {
|
||||
return fail(400, "INVALID_JSON", "必须提交JSON对象")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return fail(400, "VALIDATION_FAILED", "请求字段或类型不正确")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDEVREQ1IndependentVector(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../test/fixtures/onboarding-devreq1.json")
|
||||
must(t, err)
|
||||
var v struct {
|
||||
KeyHex, Method, Path, DeviceID, Nonce, RequestID, Body, Signature string
|
||||
KeyVersion, Timestamp int64
|
||||
}
|
||||
must(t, json.Unmarshal(raw, &v))
|
||||
key, err := hex.DecodeString(v.KeyHex)
|
||||
must(t, err)
|
||||
got := RequestMAC(key, v.Method, v.Path, v.DeviceID, v.KeyVersion, v.Timestamp, v.Nonce, v.RequestID, []byte(v.Body))
|
||||
if got != v.Signature {
|
||||
t.Fatalf("independent vector mismatch: %s", got)
|
||||
}
|
||||
if RequestMAC([]byte(v.KeyHex), v.Method, v.Path, v.DeviceID, v.KeyVersion, v.Timestamp, v.Nonce, v.RequestID, []byte(v.Body)) == v.Signature {
|
||||
t.Fatal("text key accepted as raw key")
|
||||
}
|
||||
}
|
||||
func TestStrictJSONRejectsAmbiguousBodies(t *testing.T) {
|
||||
for _, raw := range []string{`{"boot_id":"a","boot_id":"b"}`, `{"boot_id":"a","unknown":true}`, `[]`, `{} {}`, "{\"boot_id\":\"\xff\"}"} {
|
||||
var out RegisterInput
|
||||
if StrictJSON([]byte(raw), &out) == nil {
|
||||
t.Fatalf("accepted invalid body %q", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// The worker prepares encrypted configuration. Only the device executes SDK
|
||||
// registration, after a durable C08 permit. An outbox retry makes no SDK call.
|
||||
func (s *Service) ProcessActivation(ctx context.Context, event outbox.Event) error {
|
||||
if !s.options.ActivationEnabled {
|
||||
return outbox.Retryable(errors.New("onboarding activation paused"))
|
||||
}
|
||||
var in struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ActivationID string `json:"activation_id"`
|
||||
Generation int64 `json:"binding_generation"`
|
||||
}
|
||||
if json.Unmarshal(event.Payload, &in) != nil || !uuidValid(in.DeviceID) {
|
||||
return errors.New("invalid activation event")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
d, err := readDevice(ctx, tx, in.DeviceID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
j, err := readJob(ctx, tx, d.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !deviceAllowed(d) || d.Generation != in.Generation || j.Generation != d.Generation || j.ID != in.ActivationID {
|
||||
return nil
|
||||
}
|
||||
var bound bool
|
||||
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND generation=$2 AND unbound_at IS NULL)`, d.ID, d.Generation).Scan(&bound); err != nil {
|
||||
return err
|
||||
}
|
||||
if !bound {
|
||||
return nil
|
||||
}
|
||||
if err = s.expireAttempt(ctx, tx, d, &j); err != nil {
|
||||
return err
|
||||
}
|
||||
if oneOf(j.Status, "NOT_STARTED", "RESERVED", "REGISTERED_OBSERVED", "READY") {
|
||||
if err = s.ensureConfig(ctx, tx, d, &j); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) expireAttempt(ctx context.Context, tx pgx.Tx, d Device, j *activationJob) error {
|
||||
// Permit expiry means a call may already have started. It is never proof of
|
||||
// failure. Leave room for a 60-second conversation and late result delivery.
|
||||
tag, err := tx.Exec(ctx, `UPDATE device_activation_attempt SET status='UNKNOWN' WHERE activation_id=$1::uuid AND status='CONTACT_POSSIBLE' AND permit_expires_at < $2 AND executor='DEVICE'`, j.ID, s.now().Add(-90*time.Second))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='RECONCILE_REQUIRED',last_error_code='DEVICE_RESULT_UNCERTAIN',version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
j.Status = "RECONCILE_REQUIRED"
|
||||
return bump(ctx, tx, d.ID)
|
||||
}
|
||||
|
||||
func (s *Service) ensureConfig(ctx context.Context, tx pgx.Tx, d Device, j *activationJob) error {
|
||||
if j.Generation != d.Generation || !oneOf(j.Status, "NOT_STARTED", "RESERVED", "REGISTERED_OBSERVED", "READY") {
|
||||
return errState
|
||||
}
|
||||
if len(j.Config) > 0 && j.Expires != nil && j.Expires.After(s.now()) {
|
||||
return nil
|
||||
}
|
||||
if !s.options.ActivationEnabled {
|
||||
return fail(503, "ACTIVATION_PAUSED", "激活暂未开放")
|
||||
}
|
||||
// Never rotate the configuration under a potentially running SDK call.
|
||||
var busy bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_activation_attempt WHERE activation_id=$1::uuid AND status IN ('CONTACT_POSSIBLE','UNKNOWN'))`, j.ID).Scan(&busy); err != nil {
|
||||
return err
|
||||
}
|
||||
if busy {
|
||||
return fail(409, "ATTEMPT_RECONCILIATION_REQUIRED", "请先确认原执行结果")
|
||||
}
|
||||
if j.Instance == "" {
|
||||
j.Instance = s.options.InstanceID
|
||||
}
|
||||
if j.Product == "" {
|
||||
j.Product = s.options.ProductKey
|
||||
}
|
||||
if j.Bot == "" {
|
||||
j.Bot = s.options.BotID
|
||||
}
|
||||
if j.Product != s.options.ProductKey || j.Instance != s.options.InstanceID {
|
||||
return fail(409, "ACTIVATION_PRODUCT_CHANGED", "设备所属产品配置已变化,请运维核对")
|
||||
}
|
||||
if len(j.Config) > 0 {
|
||||
j.ConfigVersion++
|
||||
}
|
||||
expires := s.now().Add(24 * time.Hour)
|
||||
raw, err := json.Marshal(map[string]any{"contract_version": ContractVersion, "device_id": d.FactoryID, "activation_id": j.ID, "config_version": j.ConfigVersion, "binding_generation": d.Generation, "expires_at_ms": expires.UnixMilli(), "iot": map[string]any{"instance_id": j.Instance, "product_key": j.Product, "product_secret": s.options.ProductSecret, "device_name": j.Name}, "bot_id": j.Bot})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > 32<<10 {
|
||||
return errors.New("activation configuration exceeds limit")
|
||||
}
|
||||
j.Config, j.Digest, j.Expires, j.Ack = s.crypt.seal(raw, "config:"+j.ID), digest(raw), &expires, nil
|
||||
j.Status = "RESERVED"
|
||||
if j.Registered != nil {
|
||||
j.Status = "REGISTERED_OBSERVED"
|
||||
j.Engine = j.Registered
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET provider_instance_id=$2,provider_product_key=$3,provider_bot_id=$4,config_version=$5,config_ciphertext=$6,config_digest=$7,config_expires_at=$8,config_ack_at=NULL,engine_ready_at=$9,status=$10,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, j.Instance, j.Product, j.Bot, j.ConfigVersion, j.Config, j.Digest, expires, j.Engine, j.Status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Lifecycle == "BOUND" {
|
||||
if err = requireLifecycle(ctx, tx, d, deviceactivation.Provisioning, j.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Reuse an existing platform credential when available. The C09 contract
|
||||
// deliberately does not ask a device to upload its supplier secret.
|
||||
if len(j.Secret) > 0 {
|
||||
secret, e := s.crypt.open(j.Secret, "provider:"+j.ID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if err = s.storeRuntimeCredential(ctx, tx, d, *j, secret); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return bump(ctx, tx, d.ID)
|
||||
}
|
||||
|
||||
func (s *Service) activationCommand(ctx context.Context, tx pgx.Tx, d Device) (any, error) {
|
||||
j, err := readJob(ctx, tx, d.ID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if j.Generation != d.Generation {
|
||||
return nil, nil
|
||||
}
|
||||
if err = s.expireAttempt(ctx, tx, d, &j); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.options.ActivationEnabled || !oneOf(j.Status, "NOT_STARTED", "RESERVED", "REGISTERED_OBSERVED", "READY") {
|
||||
return nil, nil
|
||||
}
|
||||
if err = s.ensureConfig(ctx, tx, d, &j); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := s.crypt.open(j.Config, "config:"+j.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var version int64
|
||||
var encrypted []byte
|
||||
if err = tx.QueryRow(ctx, `SELECT key_version,ciphertext FROM device_onboarding_key WHERE device_id=$1::uuid AND status='ACTIVE'`, d.ID).Scan(&version, &encrypted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := s.crypt.open(encrypted, keyAAD(d.ID, version))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded := encode64(raw)
|
||||
return map[string]any{"command_id": j.ID, "type": "APPLY_ACTIVATION", "expires_at_ms": j.Expires.UnixMilli(), "payload": map[string]any{"config_b64": encoded, "config_mac": domainMAC(key, "CFG1\n"+encoded)}}, nil
|
||||
}
|
||||
|
||||
func validConfig(s *Service, d Device, j activationJob, id string, version int64) error {
|
||||
if j.ID != id || j.Generation != d.Generation || j.ConfigVersion != version || j.Status == "BLOCKED" || j.Expires == nil || !j.Expires.After(s.now()) {
|
||||
return fail(409, "ACTIVATION_CONFIG_CHANGED", "请拉取当前激活配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ActivationAckInput struct {
|
||||
ActivationID string `json:"activation_id"`
|
||||
LocalGeneration int64 `json:"local_generation"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
}
|
||||
|
||||
func (s *Service) ActivationAck(ctx context.Context, d Device, id string, raw []byte, in ActivationAckInput) (map[string]any, error) {
|
||||
if !uuidV4(in.ActivationID) || in.LocalGeneration < 0 || in.ConfigVersion < 1 {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "激活保存回执格式不正确")
|
||||
}
|
||||
return s.mutate(ctx, d, "device:"+d.ID, "activation-ack", id, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
|
||||
j, err := readJob(ctx, tx, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = validConfig(s, current, j, in.ActivationID, in.ConfigVersion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if j.Registered == nil || !oneOf(j.Status, "REGISTERED_OBSERVED", "READY") {
|
||||
return nil, fail(409, "ACTIVATION_RESULT_NOT_READY", "尚无已确认的激活结果")
|
||||
}
|
||||
var previous int64
|
||||
if err = tx.QueryRow(ctx, `SELECT CASE WHEN config_ack_boot_id=$2::uuid THEN COALESCE(config_local_generation,0) ELSE 0 END FROM device_activation_job WHERE id=$1::uuid`, j.ID, current.BootID).Scan(&previous); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.LocalGeneration < previous {
|
||||
return nil, errState
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET config_ack_at=COALESCE(config_ack_at,now()),config_local_generation=$2,config_ack_boot_id=$3::uuid,updated_at=now() WHERE id=$1::uuid`, j.ID, in.LocalGeneration, current.BootID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if j.Status != "READY" && j.ReadyGeneration != nil && *j.ReadyGeneration == current.Generation {
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='READY',version=version+1 WHERE id=$1::uuid`, j.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = bump(ctx, tx, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
st, err := s.state(ctx, tx, current)
|
||||
return map[string]any{"accepted": true, "state": st}, err
|
||||
})
|
||||
}
|
||||
|
||||
type AttemptInput struct {
|
||||
ActivationID string `json:"activation_id"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
}
|
||||
|
||||
func (s *Service) Attempt(ctx context.Context, d Device, id string, raw []byte, in AttemptInput) (map[string]any, error) {
|
||||
if !uuidV4(in.ActivationID) || !uuidV4(in.AttemptID) || in.ConfigVersion < 1 {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "执行许可参数不正确")
|
||||
}
|
||||
return s.mutate(ctx, d, "device:"+d.ID, "attempt", id, raw, 30*time.Second, func(tx pgx.Tx, current Device) (map[string]any, error) {
|
||||
j, err := readJob(ctx, tx, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = validConfig(s, current, j, in.ActivationID, in.ConfigVersion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.options.ActivationEnabled {
|
||||
return nil, fail(503, "ACTIVATION_PAUSED", "激活暂未开放")
|
||||
}
|
||||
var status string
|
||||
var version, gen int64
|
||||
var expires time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT status,config_version,binding_generation,permit_expires_at FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid AND executor='DEVICE'`, in.AttemptID, j.ID).Scan(&status, &version, &gen, &expires)
|
||||
if err == nil {
|
||||
if version != j.ConfigVersion || gen != current.Generation {
|
||||
return nil, errState
|
||||
}
|
||||
if status != "CONTACT_POSSIBLE" || !expires.After(s.now()) {
|
||||
return nil, fail(409, "ATTEMPT_ALREADY_ISSUED", "执行编号已使用或过期,请上报原执行结果")
|
||||
}
|
||||
return s.attemptResponse(ctx, tx, current, in.AttemptID, expires)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
var busy bool
|
||||
var recent int
|
||||
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_activation_attempt WHERE activation_id=$1::uuid AND status IN ('CONTACT_POSSIBLE','UNKNOWN')),count(*) FILTER(WHERE created_at>now()-interval '1 hour') FROM device_activation_attempt WHERE activation_id=$1::uuid AND executor='DEVICE'`, j.ID).Scan(&busy, &recent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if busy || oneOf(j.Status, "RECONCILE_REQUIRED", "CONTACT_POSSIBLE") {
|
||||
return nil, fail(409, "ATTEMPT_RECONCILIATION_REQUIRED", "已有执行尚未确认结果")
|
||||
}
|
||||
if recent >= 10 {
|
||||
return nil, fail(429, "ACTIVATION_ATTEMPT_LIMIT", "激活尝试过于频繁")
|
||||
}
|
||||
stage := "SDK_REGISTER"
|
||||
if j.Registered != nil {
|
||||
if j.Ack == nil {
|
||||
return nil, fail(409, "ACTIVATION_STAGE_NOT_READY", "请先保存并确认注册结果")
|
||||
}
|
||||
if j.Status == "READY" {
|
||||
return nil, fail(409, "ACTIVATION_ALREADY_READY", "本代绑定已完成激活")
|
||||
}
|
||||
stage = "CONVERSATION_TEST"
|
||||
}
|
||||
expires = s.now().Add(30 * time.Second)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_activation_attempt(id,activation_id,executor,stage,binding_generation,config_version,status,permit_expires_at) VALUES($1::uuid,$2::uuid,'DEVICE',$3,$4,$5,'CONTACT_POSSIBLE',$6)`, in.AttemptID, j.ID, stage, current.Generation, j.ConfigVersion, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='CONTACT_POSSIBLE',current_attempt_id=$2::uuid,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, in.AttemptID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = bump(ctx, tx, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.attemptResponse(ctx, tx, current, in.AttemptID, expires)
|
||||
})
|
||||
}
|
||||
func (s *Service) attemptResponse(ctx context.Context, tx pgx.Tx, d Device, id string, expires time.Time) (map[string]any, error) {
|
||||
st, err := s.state(ctx, tx, d)
|
||||
return map[string]any{"allowed": true, "attempt_id": id, "permit_expires_at_ms": expires.UnixMilli(), "state": st}, err
|
||||
}
|
||||
|
||||
type ResultInput struct {
|
||||
ActivationID string `json:"activation_id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
Stage string `json:"stage"`
|
||||
SDKResult int `json:"sdk_result"`
|
||||
Outcome string `json:"outcome"`
|
||||
}
|
||||
|
||||
func (s *Service) ActivationResult(ctx context.Context, d Device, id string, raw []byte, in ResultInput) (map[string]any, error) {
|
||||
if !uuidV4(in.ActivationID) || !uuidV4(in.AttemptID) || !oneOf(in.Stage, "SDK_REGISTER", "CONVERSATION_TEST") || !oneOf(in.Outcome, "SUCCESS_OBSERVED", "DEFINITE_FAILURE", "UNKNOWN") {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "激活结果格式不正确")
|
||||
}
|
||||
if in.Outcome == "SUCCESS_OBSERVED" && in.SDKResult != 0 {
|
||||
return nil, fail(400, "SDK_RESULT_CONFLICT", "成功结果需要SDK成功码")
|
||||
}
|
||||
return s.mutate(ctx, d, "device:"+d.ID, "activation-result", id, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
|
||||
j, err := readJob(ctx, tx, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var stage, status string
|
||||
var gen, version int64
|
||||
var stored, conversation *string
|
||||
err = tx.QueryRow(ctx, `SELECT stage,status,binding_generation,config_version,result_digest,conversation_id::text FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid AND executor='DEVICE' FOR UPDATE`, in.AttemptID, in.ActivationID).Scan(&stage, &status, &gen, &version, &stored, &conversation)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errAccess
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Result delivery can occur after permit/config expiry, but never after
|
||||
// configuration replacement or a binding change.
|
||||
if j.ID != in.ActivationID || j.Generation != current.Generation || gen != current.Generation || version != j.ConfigVersion || stage != in.Stage || j.Status == "BLOCKED" {
|
||||
return nil, errState
|
||||
}
|
||||
if stored != nil {
|
||||
if *stored != digest(raw) {
|
||||
return nil, fail(409, "ATTEMPT_RESULT_CONFLICT", "执行已有不同结果,请运维对账")
|
||||
}
|
||||
return s.activationResultResponse(ctx, tx, current, j.ID)
|
||||
}
|
||||
if !oneOf(status, "CONTACT_POSSIBLE", "UNKNOWN") {
|
||||
return nil, errState
|
||||
}
|
||||
if in.Stage == "CONVERSATION_TEST" && in.Outcome == "SUCCESS_OBSERVED" {
|
||||
if j.Registered == nil || j.Ack == nil {
|
||||
return nil, errState
|
||||
}
|
||||
// Direct SDK calls report their observation in C09. If the test used
|
||||
// SaaS orchestration, validate the already stored events by linkage.
|
||||
if conversation != nil {
|
||||
if err = validateLinkedTest(ctx, tx, current, *conversation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status=$2,sdk_result=$3,result_digest=$4,completed_at=now() WHERE id=$1::uuid`, in.AttemptID, in.Outcome, in.SDKResult, digest(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := "RESERVED"
|
||||
if j.Registered != nil {
|
||||
target = "REGISTERED_OBSERVED"
|
||||
}
|
||||
if in.Outcome == "UNKNOWN" {
|
||||
target = "RECONCILE_REQUIRED"
|
||||
}
|
||||
if in.Outcome == "SUCCESS_OBSERVED" {
|
||||
if in.Stage == "SDK_REGISTER" {
|
||||
target = "REGISTERED_OBSERVED"
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET sdk_registered_at=COALESCE(sdk_registered_at,now()),engine_ready_at=now() WHERE id=$1::uuid`, j.ID)
|
||||
} else {
|
||||
target = "READY"
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET ready_binding_generation=$2 WHERE id=$1::uuid`, j.ID, current.Generation)
|
||||
if err == nil {
|
||||
err = requireLifecycle(ctx, tx, current, deviceactivation.Active, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status=$2,last_error_code=CASE WHEN $2='RECONCILE_REQUIRED' THEN 'DEVICE_RESULT_UNCERTAIN' ELSE NULL END,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = bump(ctx, tx, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.activationResultResponse(ctx, tx, current, j.ID)
|
||||
})
|
||||
}
|
||||
func (s *Service) activationResultResponse(ctx context.Context, tx pgx.Tx, d Device, id string) (map[string]any, error) {
|
||||
st, err := s.state(ctx, tx, d)
|
||||
return map[string]any{"activation_id": id, "state": st, "reconcile_required": st.ActivationState == "RECONCILE_REQUIRED"}, err
|
||||
}
|
||||
func validateLinkedTest(ctx context.Context, tx pgx.Tx, d Device, conversation string) error {
|
||||
var valid bool
|
||||
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM conversation c JOIN hardware_agent_event a ON a.conversation_id=c.id AND a.event_type='session_started' JOIN hardware_agent_event b ON b.conversation_id=c.id AND b.event_type='first_audio_received' JOIN hardware_agent_event z ON z.conversation_id=c.id AND z.event_type='session_ended' WHERE c.id=$1::uuid AND c.device_id=$2::uuid AND c.onboarding_generation=$3 AND c.status='COMPLETED' AND a.sequence_no<b.sequence_no AND b.sequence_no<z.sequence_no AND z.created_at-a.created_at<=interval '90 seconds')`, conversation, d.ID, d.Generation).Scan(&valid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return fail(409, "TEST_EVIDENCE_REQUIRED", "关联首通尚未完成连接、首音频及结束")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (f *fixture) deviceCall(path string, body any, id string, secure bool) (int, map[string]any) {
|
||||
f.t.Helper()
|
||||
raw, ok := body.([]byte)
|
||||
if !ok {
|
||||
raw = requestJSON(body)
|
||||
}
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
path = "/api/v1/device/" + path
|
||||
r := httptest.NewRequest("POST", "https://platform.example"+path, bytes.NewReader(raw))
|
||||
if !secure {
|
||||
r.TLS = nil
|
||||
}
|
||||
nonce, timestamp := randomToken(16), time.Now().UnixMilli()
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if !strings.HasSuffix(path, "time-challenges") {
|
||||
r.Header.Set("X-Device-ID", f.d.FactoryID)
|
||||
r.Header.Set("X-Key-Version", "1")
|
||||
r.Header.Set("X-Timestamp-Ms", strconv.FormatInt(timestamp, 10))
|
||||
r.Header.Set("X-Nonce", nonce)
|
||||
r.Header.Set("X-Request-ID", id)
|
||||
r.Header.Set("Authorization", "Device-HMAC-SHA256 "+RequestMAC(f.d.Key, "POST", path, f.d.FactoryID, 1, timestamp, nonce, id, raw))
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
f.s.RegisterRoutes(mux)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, r)
|
||||
var result map[string]any
|
||||
must(f.t, json.Unmarshal(w.Body.Bytes(), &result))
|
||||
return w.Code, result
|
||||
}
|
||||
func fieldsEqual(t *testing.T, v map[string]any, fields string) {
|
||||
t.Helper()
|
||||
names := strings.Fields(fields)
|
||||
if len(v) != len(names) {
|
||||
t.Fatalf("unexpected fields: %v; expected %s", mapKeys(v), fields)
|
||||
}
|
||||
for _, name := range names {
|
||||
if _, ok := v[name]; !ok {
|
||||
t.Fatalf("missing %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
func mapKeys(v map[string]any) []string {
|
||||
keys := []string{}
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
func dataOK(t *testing.T, status int, v map[string]any, fields string) map[string]any {
|
||||
t.Helper()
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d code=%v", status, v["code"])
|
||||
}
|
||||
fieldsEqual(t, v, "ok code request_id server_time_ms data")
|
||||
d := v["data"].(map[string]any)
|
||||
fieldsEqual(t, d, fields)
|
||||
if state, ok := d["state"].(map[string]any); ok {
|
||||
fieldsEqual(t, state, "device_id device_revision binding_generation binding_state activation_state state_hash")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestIntegrationDeviceWireContract(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// C01 uses plain HTTP without a device Authorization header. Only a signed
|
||||
// time packet is returned; key material and business data are absent.
|
||||
in := TimeChallengeInput{DeviceID: f.d.FactoryID, KeyVersion: 1, BootID: f.d.BootID, ClientNonce: randomToken(16)}
|
||||
status, response := f.deviceCall("time-challenges", in, "", false)
|
||||
if status != 200 {
|
||||
t.Fatal(status)
|
||||
}
|
||||
fieldsEqual(t, response, "payload_b64 mac_hex")
|
||||
if !sameSecret(domainMAC(f.d.Key, "TIME1\n"+response["payload_b64"].(string)), response["mac_hex"].(string)) {
|
||||
t.Fatal("time MAC")
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(response["payload_b64"].(string))
|
||||
must(t, err)
|
||||
var packet map[string]any
|
||||
must(t, json.Unmarshal(decoded, &packet))
|
||||
fieldsEqual(t, packet, "device_id boot_id client_nonce challenge_id server_time_ms expires_at_ms")
|
||||
if packet["client_nonce"] != in.ClientNonce || packet["boot_id"] != in.BootID || packet["device_id"] != in.DeviceID {
|
||||
t.Fatal("time echo")
|
||||
}
|
||||
reg := RegisterInput{ChallengeID: packet["challenge_id"].(string), BootID: f.d.BootID, Model: f.d.Model, HardwareRevision: f.d.HardwareRevision, FirmwareVersion: "1.2.0", SDKVersion: "1.0.0"}
|
||||
status, response = f.deviceCall("register", reg, "", true)
|
||||
dataOK(t, status, response, "state contract_version")
|
||||
setup := SetupInput{BootID: f.d.BootID, LocalSetupID: uuid.NewString(), Transport: "LOCAL_WEB"}
|
||||
// Keep the established fixture setup: a repeated creation with the same
|
||||
// local identity is independently checked in the setup/recovery tests.
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT local_setup_id::text FROM device_setup_session WHERE id=$1::uuid`, f.setup).Scan(&setup.LocalSetupID))
|
||||
status, response = f.deviceCall("setup-sessions", setup, "", true)
|
||||
dataOK(t, status, response, "setup_id expires_at_ms poll_after_ms state")
|
||||
handoff := HandoffInput{SetupID: f.setup, LocalID: setup.LocalSetupID, Challenge: encode64(make([]byte, 32))}
|
||||
status, response = f.deviceCall("handoffs", handoff, "", true)
|
||||
dataOK(t, status, response, "handoff_token expires_at_ms entry_url")
|
||||
f.pending()
|
||||
status, response = f.deviceCall("claim-confirmations", f.confirm, "", true)
|
||||
dataOK(t, status, response, "claim_id state already_applied")
|
||||
f.refresh()
|
||||
poll := PollInput{SetupID: f.setup, BootID: f.d.BootID}
|
||||
status, response = f.deviceCall("poll", poll, "", true)
|
||||
data := dataOK(t, status, response, "state command poll_after_ms")
|
||||
st := data["state"].(map[string]any)
|
||||
ack := StateAckInput{Revision: int64(st["device_revision"].(float64)), Generation: f.d.Generation, Hash: st["state_hash"].(string), LocalGeneration: 1}
|
||||
ackID := uuid.NewString()
|
||||
status, response = f.deviceCall("state-acks", ack, ackID, true)
|
||||
dataOK(t, status, response, "accepted_revision setup_can_close")
|
||||
f.s.now = func() time.Time { return time.Now().Add(31 * time.Second) }
|
||||
status, response = f.deviceCall("state-acks", ack, ackID, true)
|
||||
if !dataOK(t, status, response, "accepted_revision setup_can_close")["setup_can_close"].(bool) {
|
||||
t.Fatal("ACK replay kept stale close result")
|
||||
}
|
||||
f.s.now = time.Now
|
||||
j := f.job()
|
||||
attempt := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
|
||||
status, response = f.deviceCall("activation-attempts", attempt, "", true)
|
||||
dataOK(t, status, response, "allowed attempt_id permit_expires_at_ms state")
|
||||
result := ResultInput{ActivationID: j.ID, AttemptID: attempt.AttemptID, Stage: "SDK_REGISTER", Outcome: "SUCCESS_OBSERVED"}
|
||||
status, response = f.deviceCall("activation-results", result, "", true)
|
||||
dataOK(t, status, response, "activation_id state reconcile_required")
|
||||
activationAck := ActivationAckInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, LocalGeneration: 2}
|
||||
status, response = f.deviceCall("activation-acks", activationAck, "", true)
|
||||
dataOK(t, status, response, "accepted state")
|
||||
attempt.AttemptID = uuid.NewString()
|
||||
status, response = f.deviceCall("activation-attempts", attempt, "", true)
|
||||
dataOK(t, status, response, "allowed attempt_id permit_expires_at_ms state")
|
||||
result.AttemptID = attempt.AttemptID
|
||||
result.Stage = "CONVERSATION_TEST"
|
||||
status, response = f.deviceCall("activation-results", result, "", true)
|
||||
data = dataOK(t, status, response, "activation_id state reconcile_required")
|
||||
st = data["state"].(map[string]any)
|
||||
if st["activation_state"] != "READY" {
|
||||
t.Fatal("direct SDK first call not READY")
|
||||
}
|
||||
closeInput := CloseInput{SetupID: f.setup, Reason: "CANCELLED", Revision: int64(st["device_revision"].(float64))}
|
||||
status, response = f.deviceCall("setup-close", closeInput, "", true)
|
||||
dataOK(t, status, response, "close_allowed state")
|
||||
}
|
||||
|
||||
func TestIntegrationDeviceRejectsRemovedAndMissingFields(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
for _, body := range []map[string]any{
|
||||
{"setup_id": f.setup, "boot_id": f.d.BootID},
|
||||
{"setup_id": f.setup, "boot_id": f.d.BootID, "known_revision": nil},
|
||||
{"setup_id": f.setup, "boot_id": f.d.BootID, "known_revision": 2147483648},
|
||||
} {
|
||||
status, r := f.deviceCall("poll", body, "", true)
|
||||
if status != 400 {
|
||||
t.Fatal("invalid required counter", status)
|
||||
}
|
||||
fieldsEqual(t, r, "ok code request_id message")
|
||||
}
|
||||
for path, body := range map[string]map[string]any{
|
||||
"activation-attempts": {"activation_id": uuid.NewString(), "config_version": 1, "attempt_id": uuid.NewString(), "stage": "SDK_REGISTER"},
|
||||
"activation-results": {"activation_id": uuid.NewString(), "attempt_id": uuid.NewString(), "stage": "SDK_REGISTER", "sdk_result": 0, "outcome": "SUCCESS_OBSERVED", "evidence": map[string]any{}},
|
||||
"activation-acks": {"activation_id": uuid.NewString(), "config_version": 1, "local_generation": 1, "config_digest": strings.Repeat("a", 64)},
|
||||
} {
|
||||
status, _ := f.deviceCall(path, body, "", true)
|
||||
if status != 400 {
|
||||
t.Fatal(path, status)
|
||||
}
|
||||
}
|
||||
status, r := f.deviceCall("poll", PollInput{SetupID: f.setup, BootID: f.d.BootID}, "", false)
|
||||
if status != 400 || r["code"] != "HTTPS_REQUIRED" {
|
||||
t.Fatal("business HTTP accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationRebootInvalidatesUncertainPermit(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.bound()
|
||||
must(t, f.s.ProcessActivation(f.ctx, f.event()))
|
||||
f.refresh()
|
||||
j := f.job()
|
||||
input := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
|
||||
id := uuid.NewString()
|
||||
_, err := f.s.Attempt(f.ctx, f.d, id, requestJSON(input), input)
|
||||
must(t, err)
|
||||
boot := uuid.NewString()
|
||||
challenge, err := f.s.TimeChallenge(f.ctx, TimeChallengeInput{DeviceID: f.d.FactoryID, KeyVersion: 1, BootID: boot, ClientNonce: randomToken(16)})
|
||||
must(t, err)
|
||||
raw, err := base64.RawURLEncoding.DecodeString(challenge["payload_b64"].(string))
|
||||
must(t, err)
|
||||
var packet map[string]any
|
||||
must(t, json.Unmarshal(raw, &packet))
|
||||
reg := RegisterInput{ChallengeID: packet["challenge_id"].(string), BootID: boot, Model: f.d.Model, HardwareRevision: f.d.HardwareRevision, FirmwareVersion: "1.2", SDKVersion: "1.0"}
|
||||
_, err = f.s.Register(f.ctx, f.d, uuid.NewString(), requestJSON(reg), reg)
|
||||
must(t, err)
|
||||
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
|
||||
must(t, err)
|
||||
_, err = f.s.Attempt(f.ctx, f.d, id, requestJSON(input), input)
|
||||
if err == nil {
|
||||
t.Fatal("reboot replayed an uncertain SDK permit")
|
||||
}
|
||||
input.AttemptID = uuid.NewString()
|
||||
_, err = f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(input), input)
|
||||
if err == nil {
|
||||
t.Fatal("reboot issued another SDK permit")
|
||||
}
|
||||
if f.job().Status != "RECONCILE_REQUIRED" {
|
||||
t.Fatal("reboot lost uncertainty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Device tables define every request field as required, including zero-valued
|
||||
// counters. Go's JSON decoder alone cannot distinguish zero from omission.
|
||||
func decodeDeviceCall[T any](raw []byte, fn func(T) (map[string]any, error)) (map[string]any, error) {
|
||||
var in T
|
||||
if err := StrictJSON(raw, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := reflect.ValueOf(in)
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
name := strings.Split(v.Type().Field(i).Tag.Get("json"), ",")[0]
|
||||
if name == "-" || name == "" {
|
||||
continue
|
||||
}
|
||||
value, present := fields[name]
|
||||
if !present || string(value) == "null" {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "缺少必填字段:"+name)
|
||||
}
|
||||
if v.Field(i).Kind() == reflect.Int64 || v.Field(i).Kind() == reflect.Int {
|
||||
n := v.Field(i).Int()
|
||||
minimum := int64(0)
|
||||
if name == "sdk_result" {
|
||||
minimum = -2147483648
|
||||
}
|
||||
if n < minimum || n > 2147483647 {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "整数超出范围:"+name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fn(in)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/embeddingprovider"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
"data-terminal-cloud/internal/orchestration"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"data-terminal-cloud/internal/rtc"
|
||||
"github.com/google/uuid"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIntegrationFullFirstCallThroughOrchestration(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.bound()
|
||||
j := f.registerSDK()
|
||||
// This test exercises the existing SaaS call path for a device whose supplier
|
||||
// credential is already available in the platform credential store.
|
||||
tx, err := f.s.pool.Begin(f.ctx)
|
||||
must(t, err)
|
||||
must(t, f.s.storeRuntimeCredential(f.ctx, tx, f.d, j, []byte("fixture-device-secret-at-least-32-bytes")))
|
||||
must(t, tx.Commit(f.ctx))
|
||||
ack := ActivationAckInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, LocalGeneration: 1}
|
||||
_, err = f.s.ActivationAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
|
||||
must(t, err)
|
||||
attempt := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
|
||||
_, err = f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(attempt), attempt)
|
||||
must(t, err)
|
||||
tokens, err := rtc.NewLocalTokenIssuer([]byte("first-call-fixture-token-key-32-bytes-minimum"))
|
||||
must(t, err)
|
||||
leases, err := orchestration.NewRedisLeaseCache(f.s.redis, "onboarding-test:"+uuid.NewString())
|
||||
must(t, err)
|
||||
sessions, err := orchestration.NewService(idempotency.NewExecutor(f.s.pool), orchestration.NewRepository(), audit.NewRepository(), outbox.NewStore(f.s.pool), tokens, leases, orchestration.ServiceOptions{RTCAppID: "test-app", VoiceProvider: "mock", EngineMode: "s2s_hybrid", AllocationTTL: time.Minute, TokenTTL: time.Hour, HeartbeatInterval: 10, HardwareBotID: j.Bot, HardwareTransport: j.Transport})
|
||||
must(t, err)
|
||||
must(t, sessions.ConfigureMemoryEmbedding(f.s.pool, embeddingprovider.Local{}))
|
||||
identity := deviceauth.Identity{ID: f.d.ID, PublicID: f.d.PublicID, TenantID: f.d.TenantID, Status: f.d.Status, ActivationState: f.d.Lifecycle, EnrollmentMode: "physical_confirm", BindingGeneration: f.d.Generation, ActivationTest: true}
|
||||
var revision int64
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT desired_revision FROM device_twin WHERE device_id=$1::uuid`, f.d.ID).Scan(&revision))
|
||||
input := orchestration.HardwareAgentCreateInput{DeviceTime: time.Now(), ConfigRevision: revision, RequestedGoal: "首次通话测试", Capabilities: orchestration.HardwareAgentCapabilities{HardwareAgent: true, SDKVersion: "1.0.0", Transports: []string{"high_quality"}, DynamicSessionConfig: true, ServerEventCallback: true}}
|
||||
_, _, err = sessions.CreateHardwareAgent(f.ctx, identity, uuid.NewString(), uuid.NewString(), requestJSON(input), input)
|
||||
if err != orchestration.ErrOnboardingState {
|
||||
t.Fatalf("normal call before READY: %v", err)
|
||||
}
|
||||
input.Intent = "activation_test"
|
||||
input.ActivationID = j.ID
|
||||
input.AttemptID = attempt.AttemptID
|
||||
input.PermitToken = ""
|
||||
key := uuid.NewString()
|
||||
created, replayed, err := sessions.CreateHardwareAgent(f.ctx, identity, key, uuid.NewString(), requestJSON(input), input)
|
||||
must(t, err)
|
||||
if replayed || created.Session.MaximumDurationSeconds != 60 {
|
||||
t.Fatal("test allocation missing duration")
|
||||
}
|
||||
again, replayed, err := sessions.CreateHardwareAgent(f.ctx, identity, key, uuid.NewString(), requestJSON(input), input)
|
||||
must(t, err)
|
||||
if !replayed || again.ConversationID != created.ConversationID {
|
||||
t.Fatal("test retry allocated another conversation")
|
||||
}
|
||||
_, _, err = sessions.CreateHardwareAgent(f.ctx, identity, uuid.NewString(), uuid.NewString(), requestJSON(input), input)
|
||||
if err == nil {
|
||||
t.Fatal("same permit created two conversations")
|
||||
}
|
||||
startedID, audioID, endedID := uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
event := orchestration.HardwareAgentEventInput{EventID: startedID, SequenceNo: 1, EventType: "session_started", OccurredAt: time.Now(), ProviderSessionRef: created.Runtime.ProviderSessionRef, SessionNonce: created.Runtime.SessionNonce, ProviderConnectionRef: "fixture-connection", BotID: created.Runtime.BotID, Transport: created.Runtime.Transport, ConfigDigest: created.Runtime.ConfigDigest, DynamicConfigApplied: true}
|
||||
_, err = sessions.ProcessHardwareAgentEvent(f.ctx, identity, created.ConversationID, requestJSON(event), event)
|
||||
must(t, err)
|
||||
event.EventID = audioID
|
||||
event.SequenceNo = 2
|
||||
event.EventType = "first_audio_received"
|
||||
event.OccurredAt = time.Now()
|
||||
_, err = sessions.ProcessHardwareAgentEvent(f.ctx, identity, created.ConversationID, requestJSON(event), event)
|
||||
must(t, err)
|
||||
event.EventID = endedID
|
||||
event.SequenceNo = 3
|
||||
event.EventType = "session_ended"
|
||||
event.OccurredAt = time.Now()
|
||||
_, err = sessions.ProcessHardwareAgentEvent(f.ctx, identity, created.ConversationID, requestJSON(event), event)
|
||||
must(t, err)
|
||||
result := ResultInput{ActivationID: j.ID, AttemptID: attempt.AttemptID, Stage: "CONVERSATION_TEST", Outcome: "SUCCESS_OBSERVED"}
|
||||
_, err = f.s.ActivationResult(f.ctx, f.d, uuid.NewString(), requestJSON(result), result)
|
||||
must(t, err)
|
||||
f.refresh()
|
||||
if f.d.Lifecycle != "ACTIVE" {
|
||||
t.Fatalf("lifecycle=%s", f.d.Lifecycle)
|
||||
}
|
||||
var state string
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT status FROM device_activation_job WHERE id=$1::uuid`, j.ID).Scan(&state))
|
||||
if state != "READY" {
|
||||
t.Fatal(state)
|
||||
}
|
||||
// Renewing an otherwise unchanged configuration requires durable ACK, but
|
||||
// reuses this generation's observed first-call evidence.
|
||||
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_activation_job SET config_expires_at=now()-interval '1 second' WHERE id=$1::uuid`, j.ID)
|
||||
must(t, err)
|
||||
tx, err = f.s.pool.Begin(f.ctx)
|
||||
must(t, err)
|
||||
currentJob, err := readJob(f.ctx, tx, f.d.ID)
|
||||
must(t, err)
|
||||
must(t, f.s.ensureConfig(f.ctx, tx, f.d, ¤tJob))
|
||||
must(t, tx.Commit(f.ctx))
|
||||
if currentJob.ConfigVersion <= j.ConfigVersion || currentJob.Status != "REGISTERED_OBSERVED" || currentJob.Ack != nil || currentJob.Engine == nil {
|
||||
t.Fatal("invalid renewal state")
|
||||
}
|
||||
ack.ConfigVersion = currentJob.ConfigVersion
|
||||
ack.LocalGeneration++
|
||||
_, err = f.s.ActivationAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
|
||||
must(t, err)
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT status FROM device_activation_job WHERE id=$1::uuid`, j.ID).Scan(&state))
|
||||
if state != "READY" {
|
||||
t.Fatal("renewal ACK did not restore READY")
|
||||
}
|
||||
if f.registrar.calls.Load() != 0 {
|
||||
t.Fatal("renewal repeated provider registration")
|
||||
}
|
||||
input.Intent = ""
|
||||
input.ActivationID = ""
|
||||
input.AttemptID = ""
|
||||
input.PermitToken = ""
|
||||
identity.ActivationTest = false
|
||||
identity.ActivationState = "ACTIVE"
|
||||
_, _, err = sessions.CreateHardwareAgent(f.ctx, identity, uuid.NewString(), uuid.NewString(), requestJSON(input), input)
|
||||
must(t, err)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) cookieName(account bool) string {
|
||||
n := "__Host-onboarding"
|
||||
if account {
|
||||
n = "__Host-platform-onboarding"
|
||||
}
|
||||
if s.options.AllowLocalHTTP {
|
||||
return strings.TrimPrefix(n, "__Host-")
|
||||
}
|
||||
return n
|
||||
}
|
||||
func (s *Service) secure(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
ip, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if s.options.AllowLocalHTTP && ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
for _, cidr := range s.options.TrustedProxyCIDRs {
|
||||
prefix, err := netip.ParsePrefix(cidr)
|
||||
if err == nil && prefix.Contains(ip) && r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (s *Service) sameOrigin(r *http.Request) bool {
|
||||
entry, _ := url.Parse(s.options.EntryURL)
|
||||
return r.Header.Get("Origin") == entry.Scheme+"://"+entry.Host
|
||||
}
|
||||
func (s *Service) RegisterRoutes(mux *http.ServeMux) {
|
||||
for _, name := range []string{"time-challenges", "register", "setup-sessions", "handoffs", "poll", "claim-confirmations", "state-acks", "activation-attempts", "activation-results", "activation-acks", "setup-close"} {
|
||||
s.mount(mux, "POST /api/v1/device/"+name, "device")
|
||||
}
|
||||
for _, name := range []string{"handoffs/exchange", "auth/sms/send", "auth/sms/verify", "setup/auth-link", "claims", "setup/status", "setup/ack", "claims/cancel", "profiles", "profiles/create"} {
|
||||
s.mount(mux, "POST /api/v1/web/"+name, "web")
|
||||
}
|
||||
s.mount(mux, "GET /api/v1/web/setup/session", "bootstrap")
|
||||
s.mount(mux, "GET /api/v1/web/onboarding/devices", "account")
|
||||
s.mount(mux, "GET /api/v1/web/account/session", "account-session")
|
||||
s.mount(mux, "POST /api/v1/web/auth/logout", "logout")
|
||||
s.mount(mux, "POST /api/v1/ops/device-inventory", "ops")
|
||||
s.mount(mux, "GET /api/v1/ops/device-inventory", "ops")
|
||||
for _, name := range []string{"credential-deliveries", "release", "credential-rotations"} {
|
||||
s.mount(mux, "POST /api/v1/ops/device-inventory/{id}/"+name, "ops")
|
||||
}
|
||||
s.mount(mux, "POST /api/v1/ops/onboarding-activations/{id}/reconcile", "ops")
|
||||
}
|
||||
func (s *Service) mount(mux *http.ServeMux, pattern, kind string) {
|
||||
mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { s.serve(w, r, kind) })
|
||||
}
|
||||
func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
id := r.Header.Get("X-Request-ID")
|
||||
if id == "" && (r.Method == http.MethodGet || r.URL.Path == "/api/v1/device/time-challenges") {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
if !uuidV4(id) {
|
||||
s.write(w, id, nil, fail(400, "REQUEST_ID_REQUIRED", "X-Request-ID必须是UUID v4"))
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/api/v1/device/time-challenges" && !s.secure(r) {
|
||||
s.write(w, id, nil, fail(400, "HTTPS_REQUIRED", "此接口必须通过HTTPS访问"))
|
||||
return
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if host == "" {
|
||||
host = "unknown"
|
||||
}
|
||||
if err := s.limit(r.Context(), "ip:"+host, 300, time.Minute); err != nil {
|
||||
s.write(w, id, nil, err)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/auth/sms/send") {
|
||||
if err := s.limit(r.Context(), "sms:ip:"+host, 20, time.Hour); err != nil {
|
||||
s.write(w, id, nil, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
var data any
|
||||
var err error
|
||||
switch kind {
|
||||
case "device":
|
||||
data, err = s.deviceHTTP(r, id)
|
||||
case "web":
|
||||
data, err = s.webHTTP(w, r, id)
|
||||
case "bootstrap":
|
||||
data, err = s.bootstrapHTTP(r)
|
||||
case "ops":
|
||||
data, err = s.opsHTTP(r, id)
|
||||
case "account":
|
||||
var p identity.Principal
|
||||
p, err = s.browserPrincipal(r, false)
|
||||
if err == nil {
|
||||
data, err = s.AccountDevices(r.Context(), p)
|
||||
}
|
||||
case "account-session":
|
||||
data, err = s.accountSessionHTTP(r)
|
||||
case "logout":
|
||||
data, err = s.logoutHTTP(w, r)
|
||||
}
|
||||
if r.URL.Path == "/api/v1/device/time-challenges" && err == nil {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
return
|
||||
}
|
||||
s.write(w, id, data, err)
|
||||
}
|
||||
func (s *Service) write(w http.ResponseWriter, id string, data any, err error) {
|
||||
status := 200
|
||||
code, message := "OK", ""
|
||||
if err != nil {
|
||||
data = nil
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
status = e.Status
|
||||
code = e.Code
|
||||
message = e.Message
|
||||
} else {
|
||||
var db *pgconn.PgError
|
||||
if errors.As(err, &db) && (db.Code == "23505" || db.Code == "P0001") {
|
||||
status = 409
|
||||
code = "RESOURCE_STATE_CONFLICT"
|
||||
message = "资源状态已变化,请刷新后重试"
|
||||
} else {
|
||||
status = 500
|
||||
code = "INTERNAL_ERROR"
|
||||
message = "服务暂不可用,请按原请求号重试"
|
||||
kind := "internal"
|
||||
if errors.As(err, &db) {
|
||||
kind = "postgres:" + db.Code
|
||||
}
|
||||
s.options.Logger.Error("onboarding request failed", "request_id", id, "error_type", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
if status == 429 {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
}
|
||||
if status == 503 {
|
||||
w.Header().Set("Retry-After", "2")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err != nil {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "code": code, "message": message, "request_id": id})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "code": code, "request_id": id, "server_time_ms": s.now().UnixMilli(), "data": data})
|
||||
}
|
||||
func readBody(r *http.Request) ([]byte, error) {
|
||||
if !strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") {
|
||||
return nil, fail(415, "JSON_REQUIRED", "请使用application/json")
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
|
||||
if err != nil {
|
||||
return nil, fail(400, "INVALID_JSON", "请求正文读取失败")
|
||||
}
|
||||
if len(raw) > maxBody {
|
||||
return nil, fail(413, "REQUEST_TOO_LARGE", "请求正文过长")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
func decodeCall[T any](raw []byte, fn func(T) (map[string]any, error)) (map[string]any, error) {
|
||||
var in T
|
||||
if err := StrictJSON(raw, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fn(in)
|
||||
}
|
||||
func (s *Service) deviceHTTP(r *http.Request, id string) (any, error) {
|
||||
if r.URL.RawQuery != "" {
|
||||
return nil, fail(400, "QUERY_FORBIDDEN", "设备请求不接受query参数")
|
||||
}
|
||||
ctx := r.Context()
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/device/")
|
||||
if path == "time-challenges" {
|
||||
raw, err := readBody(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeDeviceCall(raw, func(in TimeChallengeInput) (map[string]any, error) { return s.TimeChallenge(ctx, in) })
|
||||
}
|
||||
d, raw, err := s.authenticate(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.limit(ctx, "device:"+d.ID, 180, time.Minute); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") {
|
||||
return nil, fail(415, "JSON_REQUIRED", "请使用application/json")
|
||||
}
|
||||
switch path {
|
||||
case "register":
|
||||
return decodeDeviceCall(raw, func(in RegisterInput) (map[string]any, error) { return s.Register(ctx, d, id, raw, in) })
|
||||
case "setup-sessions":
|
||||
return decodeDeviceCall(raw, func(in SetupInput) (map[string]any, error) { return s.Setup(ctx, d, id, raw, in) })
|
||||
case "handoffs":
|
||||
if !s.Enabled() {
|
||||
return nil, fail(503, "ENROLLMENT_PAUSED", "首次绑定入口暂时暂停")
|
||||
}
|
||||
return decodeDeviceCall(raw, func(in HandoffInput) (map[string]any, error) { return s.Handoff(ctx, d, id, raw, in) })
|
||||
case "poll":
|
||||
return decodeDeviceCall(raw, func(in PollInput) (map[string]any, error) { return s.Poll(ctx, d, in) })
|
||||
case "claim-confirmations":
|
||||
return decodeDeviceCall(raw, func(in ConfirmationInput) (map[string]any, error) { return s.Confirm(ctx, d, id, raw, in) })
|
||||
case "state-acks":
|
||||
return decodeDeviceCall(raw, func(in StateAckInput) (map[string]any, error) { return s.StateAck(ctx, d, id, raw, in) })
|
||||
case "activation-attempts":
|
||||
return decodeDeviceCall(raw, func(in AttemptInput) (map[string]any, error) { return s.Attempt(ctx, d, id, raw, in) })
|
||||
case "activation-results":
|
||||
return decodeDeviceCall(raw, func(in ResultInput) (map[string]any, error) { return s.ActivationResult(ctx, d, id, raw, in) })
|
||||
case "activation-acks":
|
||||
return decodeDeviceCall(raw, func(in ActivationAckInput) (map[string]any, error) { return s.ActivationAck(ctx, d, id, raw, in) })
|
||||
case "setup-close":
|
||||
return decodeDeviceCall(raw, func(in CloseInput) (map[string]any, error) { return s.Close(ctx, d, id, raw, in) })
|
||||
}
|
||||
return nil, fail(404, "NOT_FOUND", "接口不存在")
|
||||
}
|
||||
func (s *Service) browserSession(r *http.Request) (WebSession, error) {
|
||||
c, err := r.Cookie(s.cookieName(false))
|
||||
if err != nil || !validB64(c.Value, 32) {
|
||||
return WebSession{}, fail(401, "WEB_SESSION_EXPIRED", "请从设备打开设置入口")
|
||||
}
|
||||
return s.webSession(r.Context(), s.pool, digest([]byte(c.Value)))
|
||||
}
|
||||
func (s *Service) issueCookies(w http.ResponseWriter, out BrowserCredentials) {
|
||||
for _, item := range []struct {
|
||||
value string
|
||||
account bool
|
||||
age int
|
||||
}{{out.Token, false, 1800}, {out.AccountToken, true, 86400}} {
|
||||
if item.value != "" {
|
||||
http.SetCookie(w, &http.Cookie{Name: s.cookieName(item.account), Value: item.value, Path: "/", Secure: !s.options.AllowLocalHTTP, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: item.age})
|
||||
}
|
||||
}
|
||||
}
|
||||
func (s *Service) bearer(r *http.Request) (identity.Principal, error) {
|
||||
if s.options.TokenVerifier == nil || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
||||
return identity.Principal{}, fail(401, "LOGIN_REQUIRED", "请先登录")
|
||||
}
|
||||
p, err := s.options.TokenVerifier.Verify(r.Context(), strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if err != nil {
|
||||
return p, fail(401, "LOGIN_REQUIRED", "登录已失效,请重新登录")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
func (s *Service) browserPrincipal(r *http.Request, write bool) (identity.Principal, error) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
return s.bearer(r)
|
||||
}
|
||||
if write && !s.sameOrigin(r) {
|
||||
return identity.Principal{}, errAccess
|
||||
}
|
||||
c, err := r.Cookie(s.cookieName(true))
|
||||
if err != nil {
|
||||
return identity.Principal{}, fail(401, "LOGIN_REQUIRED", "请先登录")
|
||||
}
|
||||
return s.AccountPrincipal(r.Context(), c.Value, r.Header.Get("X-CSRF-Token"), write)
|
||||
}
|
||||
|
||||
// Only explicitly selected customer routes receive this cookie adapter.
|
||||
func (s *Service) CustomerAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.secure(r) {
|
||||
s.write(w, r.Header.Get("X-Request-ID"), nil, fail(400, "HTTPS_REQUIRED", "此接口必须通过HTTPS访问"))
|
||||
return
|
||||
}
|
||||
p, err := s.browserPrincipal(r, r.Method != http.MethodGet && r.Method != http.MethodHead)
|
||||
if err != nil {
|
||||
s.write(w, r.Header.Get("X-Request-ID"), nil, err)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(identity.WithPrincipal(r.Context(), p)))
|
||||
})
|
||||
}
|
||||
func (s *Service) webHTTP(w http.ResponseWriter, r *http.Request, id string) (any, error) {
|
||||
if !s.sameOrigin(r) || r.URL.RawQuery != "" {
|
||||
return nil, errAccess
|
||||
}
|
||||
raw, err := readBody(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/web/")
|
||||
ctx := r.Context()
|
||||
if path == "handoffs/exchange" {
|
||||
if !s.Enabled() {
|
||||
return nil, fail(503, "ENROLLMENT_PAUSED", "首次绑定入口暂时暂停")
|
||||
}
|
||||
var in ExchangeInput
|
||||
if err = StrictJSON(raw, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, e := s.Exchange(ctx, in)
|
||||
if e == nil {
|
||||
s.issueCookies(w, out)
|
||||
}
|
||||
return out, e
|
||||
}
|
||||
ws, err := s.browserSession(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.checkCSRF(ctx, ws.Hash, r.Header.Get("X-CSRF-Token")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch path {
|
||||
case "setup/auth-link":
|
||||
if err = StrictJSON(raw, &struct{}{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, e := s.bearer(r)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out, e := s.AuthLink(ctx, ws, p)
|
||||
if e == nil {
|
||||
s.issueCookies(w, out)
|
||||
}
|
||||
return out, e
|
||||
case "auth/sms/send":
|
||||
return decodeCall(raw, func(in SMSSendInput) (map[string]any, error) { return s.SendSMS(ctx, ws, id, in) })
|
||||
case "auth/sms/verify":
|
||||
var in SMSVerifyInput
|
||||
if err = StrictJSON(raw, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, e := s.VerifySMS(ctx, ws, in)
|
||||
if e == nil {
|
||||
s.issueCookies(w, out)
|
||||
}
|
||||
return out, e
|
||||
case "claims":
|
||||
if !s.Enabled() || s.options.PauseClaims {
|
||||
return nil, fail(503, "CLAIMS_PAUSED", "新的绑定申请暂时暂停")
|
||||
}
|
||||
return decodeCall(raw, func(in ClaimInput) (map[string]any, error) { return s.Claim(ctx, ws, id, raw, in) })
|
||||
case "claims/cancel":
|
||||
return decodeCall(raw, func(in CancelInput) (map[string]any, error) { return s.Cancel(ctx, ws, id, raw, in) })
|
||||
case "setup/status":
|
||||
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.WebStatus(ctx, ws) })
|
||||
case "setup/ack":
|
||||
return decodeCall(raw, func(in WebAckInput) (map[string]any, error) { return s.WebAck(ctx, ws, id, raw, in) })
|
||||
case "profiles":
|
||||
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.Profiles(ctx, ws) })
|
||||
case "profiles/create":
|
||||
return decodeCall(raw, func(in ProfileInput) (map[string]any, error) { return s.CreateProfile(ctx, ws, id, raw, in) })
|
||||
}
|
||||
return nil, fail(404, "NOT_FOUND", "接口不存在")
|
||||
}
|
||||
func (s *Service) bootstrapHTTP(r *http.Request) (any, error) {
|
||||
if r.URL.RawQuery != "" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
|
||||
return nil, errAccess
|
||||
}
|
||||
ws, err := s.browserSession(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := randomToken(32)
|
||||
_, err = s.pool.Exec(r.Context(), `UPDATE onboarding_web_session SET csrf_hash=$2 WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now()`, ws.Hash, digest([]byte(token)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := s.WebStatus(r.Context(), ws)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c, e := r.Cookie(s.cookieName(true)); e == nil && validB64(c.Value, 32) && ws.UserID != "" {
|
||||
_, err = s.pool.Exec(r.Context(), `UPDATE onboarding_account_session SET csrf_hash=$2 WHERE token_hash=$1 AND user_id=$3::uuid AND expires_at>now() AND revoked_at IS NULL`, digest([]byte(c.Value)), digest([]byte(token)), ws.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return map[string]any{"csrf_token": token, "data": data}, nil
|
||||
}
|
||||
func (s *Service) opsHTTP(r *http.Request, id string) (any, error) {
|
||||
p, err := s.bearer(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenant := r.Header.Get("X-Tenant-ID")
|
||||
ctx := r.Context()
|
||||
if r.Method == http.MethodGet {
|
||||
return s.OpsList(ctx, p, tenant)
|
||||
}
|
||||
raw, err := readBody(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
device := r.PathValue("id")
|
||||
switch {
|
||||
case r.URL.Path == "/api/v1/ops/device-inventory":
|
||||
if !s.Enabled() {
|
||||
return nil, fail(503, "ENROLLMENT_PAUSED", "生产登记入口暂停")
|
||||
}
|
||||
return decodeCall(raw, func(in InventoryInput) (map[string]any, error) {
|
||||
return s.CreateInventory(ctx, p, tenant, id, r.Header.Get("Idempotency-Key"), raw, in)
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/credential-deliveries"):
|
||||
return decodeCall(raw, func(in DeliveryInput) (map[string]any, error) {
|
||||
return s.DeliverCredential(ctx, p, tenant, device, id, in)
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/release"):
|
||||
return decodeCall(raw, func(in ReleaseInput) (map[string]any, error) {
|
||||
return s.ReleaseInventory(ctx, p, tenant, device, id, in)
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/credential-rotations"):
|
||||
return decodeCall(raw, func(in RotationInput) (map[string]any, error) {
|
||||
return s.Rotate(ctx, p, tenant, device, r.Header.Get("Idempotency-Key"), id, raw, in)
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/reconcile"):
|
||||
return decodeCall(raw, func(in ReconcileInput) (map[string]any, error) { return s.Reconcile(ctx, p, tenant, device, id, in) })
|
||||
}
|
||||
return nil, fail(404, "NOT_FOUND", "接口不存在")
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/hardwareprovision"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type testRegistrar struct {
|
||||
calls atomic.Int32
|
||||
err error
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (r *testRegistrar) Register(ctx context.Context, in hardwareprovision.RegisterInput) (hardwareprovision.RegisterResult, error) {
|
||||
r.calls.Add(1)
|
||||
if r.started != nil {
|
||||
close(r.started)
|
||||
select {
|
||||
case <-r.release:
|
||||
case <-ctx.Done():
|
||||
return hardwareprovision.RegisterResult{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
return hardwareprovision.RegisterResult{DeviceSecret: "fixture-device-secret-at-least-32-bytes", RTCAppID: "test-app"}, r.err
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
t *testing.T
|
||||
s *Service
|
||||
ctx context.Context
|
||||
tenant, tenantPublic, operatorID string
|
||||
p, user identity.Principal
|
||||
d Device
|
||||
web WebSession
|
||||
setup, elder, claim string
|
||||
confirm ConfirmationInput
|
||||
registrar *testRegistrar
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL required")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
must(t, err)
|
||||
t.Cleanup(pool.Close)
|
||||
addr := os.Getenv("TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:56379"
|
||||
}
|
||||
cache := redis.NewClient(&redis.Options{Addr: addr})
|
||||
must(t, cache.Ping(ctx).Err())
|
||||
t.Cleanup(func() { _ = cache.Close() })
|
||||
f := &fixture{t: t, ctx: ctx, tenant: uuid.NewString(), tenantPublic: uuid.NewString(), operatorID: uuid.NewString(), p: identity.Principal{Issuer: "urn:onboarding:test", Subject: uuid.NewString()}, user: identity.Principal{Issuer: "urn:onboarding:test", Subject: uuid.NewString(), DisplayName: "测试用户1234"}, registrar: &testRegistrar{}}
|
||||
f.s, err = NewService(pool, cache, Options{Enabled: true, MasterKey: strings.Repeat("15", 32), ProviderMasterKey: strings.Repeat("25", 32), EntryURL: "https://platform.example/onboarding/entry", SelfSignupTenantIDs: []string{f.tenantPublic}, ActivationEnabled: true, Registrar: f.registrar, InstanceID: "test-instance", ProductKey: "test-product", ProductSecret: "fixture-product-secret-at-least-32-bytes", BotID: "test-bot", Transport: "high_quality", MinimumSDKVersion: "1.0.0"})
|
||||
must(t, err)
|
||||
_, err = pool.Exec(ctx, `INSERT INTO tenant(id,public_id,name) VALUES($1::uuid,$2::uuid,'onboarding integration')`, f.tenant, f.tenantPublic)
|
||||
must(t, err)
|
||||
_, err = pool.Exec(ctx, `INSERT INTO user_account(id,tenant_id,oidc_issuer,oidc_subject,display_name,platform_role,account_kind) VALUES($1::uuid,$2::uuid,$3,$4,'生产管理员','ops_admin','operator')`, f.operatorID, f.tenant, f.p.Issuer, f.p.Subject)
|
||||
must(t, err)
|
||||
mac := strings.ToUpper(strings.ReplaceAll(uuid.NewString(), "-", ""))[:12]
|
||||
in := InventoryInput{FactoryMAC: mac, Model: "KP01", HardwareRevision: "A1", BatchNo: "test"}
|
||||
raw := requestJSON(in)
|
||||
key := uuid.NewString()
|
||||
inv, err := f.s.CreateInventory(ctx, f.p, f.tenantPublic, uuid.NewString(), key, raw, in)
|
||||
must(t, err)
|
||||
again, err := f.s.CreateInventory(ctx, f.p, f.tenantPublic, uuid.NewString(), key, raw, in)
|
||||
must(t, err)
|
||||
if inv["platform_device_id"] != again["platform_device_id"] {
|
||||
t.Fatal("inventory identity changed")
|
||||
}
|
||||
public := inv["platform_device_id"].(string)
|
||||
job := inv["enrollment_job_id"].(string)
|
||||
receiver, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
must(t, err)
|
||||
pub, err := x509.MarshalPKIXPublicKey(&receiver.PublicKey)
|
||||
must(t, err)
|
||||
_, err = f.s.DeliverCredential(ctx, f.p, f.tenantPublic, public, uuid.NewString(), DeliveryInput{JobID: job, ReceiverKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub}))})
|
||||
must(t, err)
|
||||
_, err = f.s.ReleaseInventory(ctx, f.p, f.tenantPublic, public, uuid.NewString(), ReleaseInput{JobID: job, Evidence: "fixture-burn-readback", ExpectedVersion: 1})
|
||||
must(t, err)
|
||||
f.d, err = f.s.findFactoryDevice(ctx, "KP01-"+mac, 1)
|
||||
must(t, err)
|
||||
boot := uuid.NewString()
|
||||
challenge, err := f.s.TimeChallenge(ctx, TimeChallengeInput{DeviceID: f.d.FactoryID, KeyVersion: 1, BootID: boot, ClientNonce: randomToken(16)})
|
||||
must(t, err)
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(challenge["payload_b64"].(string))
|
||||
must(t, err)
|
||||
var packet map[string]any
|
||||
must(t, json.Unmarshal(decoded, &packet))
|
||||
reg := RegisterInput{ChallengeID: packet["challenge_id"].(string), BootID: boot, Model: f.d.Model, HardwareRevision: f.d.HardwareRevision, FirmwareVersion: "1.2.0", SDKVersion: "1.0.0"}
|
||||
requestID := uuid.NewString()
|
||||
_, err = f.s.Register(ctx, f.d, requestID, requestJSON(reg), reg)
|
||||
must(t, err)
|
||||
f.refresh()
|
||||
_, err = f.s.Register(ctx, f.d, requestID, requestJSON(reg), reg)
|
||||
must(t, err)
|
||||
setupIn := SetupInput{BootID: boot, LocalSetupID: uuid.NewString(), Transport: "LOCAL_WEB"}
|
||||
setup, err := f.s.Setup(ctx, f.d, uuid.NewString(), requestJSON(setupIn), setupIn)
|
||||
must(t, err)
|
||||
f.setup = setup["setup_id"].(string)
|
||||
verifier := randomToken(32)
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
handoffIn := HandoffInput{SetupID: f.setup, LocalID: setupIn.LocalSetupID, Challenge: encode64(sum[:])}
|
||||
handoff, err := f.s.Handoff(ctx, f.d, uuid.NewString(), requestJSON(handoffIn), handoffIn)
|
||||
must(t, err)
|
||||
credentials, err := f.s.Exchange(ctx, ExchangeInput{Token: handoff["handoff_token"].(string), Verifier: verifier})
|
||||
must(t, err)
|
||||
_, err = f.s.Exchange(ctx, ExchangeInput{Token: handoff["handoff_token"].(string), Verifier: verifier})
|
||||
if err == nil {
|
||||
t.Fatal("handoff redeemed twice")
|
||||
}
|
||||
ws, err := f.s.webSession(ctx, pool, digest([]byte(credentials.Token)))
|
||||
must(t, err)
|
||||
credentials, err = f.s.AuthLink(ctx, ws, f.user)
|
||||
must(t, err)
|
||||
f.web, err = f.s.webSession(ctx, pool, digest([]byte(credentials.Token)))
|
||||
must(t, err)
|
||||
profile := ProfileInput{HouseholdName: "测试家庭", Name: "奶奶"}
|
||||
result, err := f.s.CreateProfile(ctx, f.web, uuid.NewString(), requestJSON(profile), profile)
|
||||
must(t, err)
|
||||
f.elder = result["elder_id"].(string)
|
||||
return f
|
||||
}
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func (f *fixture) refresh() {
|
||||
f.t.Helper()
|
||||
var err error
|
||||
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
|
||||
must(f.t, err)
|
||||
if f.web.Hash != "" {
|
||||
f.web, err = f.s.webSession(f.ctx, f.s.pool, f.web.Hash)
|
||||
must(f.t, err)
|
||||
}
|
||||
}
|
||||
func (f *fixture) pending() {
|
||||
f.t.Helper()
|
||||
in := ClaimInput{ElderID: f.elder, Generation: f.d.Generation}
|
||||
result, err := f.s.Claim(f.ctx, f.web, uuid.NewString(), requestJSON(in), in)
|
||||
must(f.t, err)
|
||||
f.claim = result["claim_id"].(string)
|
||||
poll, err := f.s.Poll(f.ctx, f.d, PollInput{SetupID: f.setup, BootID: f.d.BootID})
|
||||
must(f.t, err)
|
||||
payload := poll["command"].(map[string]any)["payload"].(map[string]any)
|
||||
f.confirm = ConfirmationInput{ClaimID: f.claim, Challenge: payload["challenge"].(string), Digest: payload["binding_digest"].(string), ExpectedGeneration: f.d.Generation, PressEventID: uuid.NewString()}
|
||||
}
|
||||
func (f *fixture) bound() {
|
||||
f.t.Helper()
|
||||
f.pending()
|
||||
_, err := f.s.Confirm(f.ctx, f.d, uuid.NewString(), requestJSON(f.confirm), f.confirm)
|
||||
must(f.t, err)
|
||||
f.refresh()
|
||||
}
|
||||
func (f *fixture) event() outbox.Event {
|
||||
f.t.Helper()
|
||||
var e outbox.Event
|
||||
err := f.s.pool.QueryRow(f.ctx, `SELECT id::text,payload FROM outbox_event WHERE aggregate_id=$1::uuid AND event_type=$2 ORDER BY created_at DESC LIMIT 1`, f.d.ID, ActivationEvent).Scan(&e.ID, &e.Payload)
|
||||
must(f.t, err)
|
||||
return e
|
||||
}
|
||||
|
||||
func TestIntegrationPhysicalConfirmationAndReplay(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.pending()
|
||||
var count int
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM device_binding WHERE device_id=$1::uuid`, f.d.ID).Scan(&count))
|
||||
if count != 0 {
|
||||
t.Fatal("claim established ownership before physical confirmation")
|
||||
}
|
||||
id := uuid.NewString()
|
||||
_, err := f.s.Confirm(f.ctx, f.d, id, requestJSON(f.confirm), f.confirm)
|
||||
must(t, err)
|
||||
f.refresh()
|
||||
if f.d.Generation != 1 {
|
||||
t.Fatal("wrong binding generation")
|
||||
}
|
||||
_, err = f.s.Confirm(f.ctx, f.d, id, requestJSON(f.confirm), f.confirm)
|
||||
must(t, err)
|
||||
status, err := f.s.WebStatus(f.ctx, f.web)
|
||||
must(t, err)
|
||||
if status["binding_id"] == nil {
|
||||
t.Fatal("binding result not recoverable")
|
||||
}
|
||||
_, err = f.s.pool.Exec(f.ctx, `UPDATE household_member SET status='removed' WHERE user_id=$1::uuid`, f.web.UserID)
|
||||
must(t, err)
|
||||
_, err = f.s.Confirm(f.ctx, f.d, id, requestJSON(f.confirm), f.confirm)
|
||||
if !errors.Is(err, errAccess) {
|
||||
t.Fatalf("replay bypassed revoked permission: %v", err)
|
||||
}
|
||||
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID)
|
||||
must(t, err)
|
||||
current, err := readDevice(f.ctx, f.s.pool, f.d.ID, false)
|
||||
must(t, err)
|
||||
if current.Generation != 2 {
|
||||
t.Fatal("unbind did not increment generation")
|
||||
}
|
||||
_, err = f.s.Confirm(f.ctx, current, id, requestJSON(f.confirm), f.confirm)
|
||||
if err == nil {
|
||||
t.Fatal("old confirmation revived unbound owner")
|
||||
}
|
||||
}
|
||||
func TestIntegrationConcurrentClaimsAndCancel(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
in := ClaimInput{ElderID: f.elder, Generation: 0}
|
||||
var winners atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := f.s.Claim(f.ctx, f.web, uuid.NewString(), requestJSON(in), in)
|
||||
if err == nil {
|
||||
winners.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if winners.Load() != 1 {
|
||||
t.Fatalf("claim winners=%d", winners.Load())
|
||||
}
|
||||
var id string
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT id::text FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING'`, f.d.ID).Scan(&id))
|
||||
cancel := CancelInput{ClaimID: id, Generation: 0}
|
||||
_, err := f.s.Cancel(f.ctx, f.web, uuid.NewString(), requestJSON(cancel), cancel)
|
||||
must(t, err)
|
||||
var challenge, hash string
|
||||
var encrypted []byte
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT challenge_ciphertext,binding_digest FROM device_binding_claim WHERE id=$1::uuid`, id).Scan(&encrypted, &hash))
|
||||
plain, err := f.s.crypt.open(encrypted, "claim:"+id)
|
||||
must(t, err)
|
||||
challenge = string(plain)
|
||||
confirm := ConfirmationInput{ClaimID: id, Challenge: challenge, Digest: hash, ExpectedGeneration: 0, PressEventID: uuid.NewString()}
|
||||
_, err = f.s.Confirm(f.ctx, f.d, uuid.NewString(), requestJSON(confirm), confirm)
|
||||
if err == nil {
|
||||
t.Fatal("cancelled claim confirmed")
|
||||
}
|
||||
}
|
||||
func (f *fixture) job() activationJob {
|
||||
f.t.Helper()
|
||||
tx, err := f.s.pool.Begin(f.ctx)
|
||||
must(f.t, err)
|
||||
defer tx.Rollback(f.ctx)
|
||||
j, err := readJob(f.ctx, tx, f.d.ID)
|
||||
must(f.t, err)
|
||||
return j
|
||||
}
|
||||
func (f *fixture) registerSDK() activationJob {
|
||||
f.t.Helper()
|
||||
must(f.t, f.s.ProcessActivation(f.ctx, f.event()))
|
||||
f.refresh()
|
||||
j := f.job()
|
||||
attempt := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
|
||||
_, err := f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(attempt), attempt)
|
||||
must(f.t, err)
|
||||
result := ResultInput{ActivationID: j.ID, AttemptID: attempt.AttemptID, Stage: "SDK_REGISTER", Outcome: "SUCCESS_OBSERVED"}
|
||||
_, err = f.s.ActivationResult(f.ctx, f.d, uuid.NewString(), requestJSON(result), result)
|
||||
must(f.t, err)
|
||||
return f.job()
|
||||
}
|
||||
func TestIntegrationDeviceUnknownDoesNotRegisterAgain(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.bound()
|
||||
must(t, f.s.ProcessActivation(f.ctx, f.event()))
|
||||
f.refresh()
|
||||
j := f.job()
|
||||
attempt := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
|
||||
permit, err := f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(attempt), attempt)
|
||||
must(t, err)
|
||||
if !permit["allowed"].(bool) || time.UnixMilli(permit["permit_expires_at_ms"].(int64)).Sub(time.Now()) > 30*time.Second {
|
||||
t.Fatal("permit contract")
|
||||
}
|
||||
result := ResultInput{ActivationID: j.ID, AttemptID: attempt.AttemptID, Stage: "SDK_REGISTER", SDKResult: -1, Outcome: "UNKNOWN"}
|
||||
response, err := f.s.ActivationResult(f.ctx, f.d, uuid.NewString(), requestJSON(result), result)
|
||||
must(t, err)
|
||||
if response["reconcile_required"] != true {
|
||||
t.Fatal("unknown must require reconciliation")
|
||||
}
|
||||
attempt.AttemptID = uuid.NewString()
|
||||
_, err = f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(attempt), attempt)
|
||||
if err == nil {
|
||||
t.Fatal("unknown issued another execution")
|
||||
}
|
||||
must(t, f.s.ProcessActivation(f.ctx, f.event()))
|
||||
must(t, f.s.ProcessActivation(f.ctx, f.event()))
|
||||
if f.registrar.calls.Load() != 0 {
|
||||
t.Fatal("SaaS invoked supplier registration")
|
||||
}
|
||||
var bound bool
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, f.d.ID).Scan(&bound))
|
||||
if !bound {
|
||||
t.Fatal("unknown removed binding")
|
||||
}
|
||||
}
|
||||
func TestIntegrationDeviceConfigAndRegistration(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.bound()
|
||||
must(t, f.s.ProcessActivation(f.ctx, f.event()))
|
||||
f.refresh()
|
||||
response, err := f.s.Poll(f.ctx, f.d, PollInput{SetupID: f.setup, BootID: f.d.BootID})
|
||||
must(t, err)
|
||||
payload := response["command"].(map[string]any)["payload"].(map[string]any)
|
||||
if len(payload) != 2 {
|
||||
t.Fatal("extra activation command fields")
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(payload["config_b64"].(string))
|
||||
must(t, err)
|
||||
var config map[string]any
|
||||
must(t, json.Unmarshal(raw, &config))
|
||||
if len(config) != 8 || config["contract_version"] != "ONBOARDING-1.1" {
|
||||
t.Fatal("wrong activation config")
|
||||
}
|
||||
iot := config["iot"].(map[string]any)
|
||||
if len(iot) != 4 || iot["product_secret"] != f.s.options.ProductSecret || iot["device_name"] != f.d.FactoryID {
|
||||
t.Fatal("wrong SDK registration config")
|
||||
}
|
||||
if !sameSecret(domainMAC(f.d.Key, "CFG1\n"+payload["config_b64"].(string)), payload["config_mac"].(string)) {
|
||||
t.Fatal("config MAC")
|
||||
}
|
||||
j := f.job()
|
||||
ack := ActivationAckInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, LocalGeneration: 1}
|
||||
_, err = f.s.ActivationAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
|
||||
if err == nil {
|
||||
t.Fatal("ACK before registration was accepted")
|
||||
}
|
||||
f.registerSDK()
|
||||
_, err = f.s.ActivationAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
|
||||
must(t, err)
|
||||
if f.job().Status != "REGISTERED_OBSERVED" {
|
||||
t.Fatal("registration falsely implies first call")
|
||||
}
|
||||
if f.registrar.calls.Load() != 0 {
|
||||
t.Fatal("server performed SDK registration")
|
||||
}
|
||||
}
|
||||
func TestIntegrationDEVREQReplayAndTLS(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
in := PollInput{SetupID: f.setup, BootID: f.d.BootID}
|
||||
raw := requestJSON(in)
|
||||
path := "/api/v1/device/poll"
|
||||
id := uuid.NewString()
|
||||
nonce := randomToken(16)
|
||||
timestamp := time.Now().UnixMilli()
|
||||
request := func() *http.Request {
|
||||
r := httptest.NewRequest("POST", "https://platform.example"+path, bytes.NewReader(raw))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("X-Device-ID", f.d.FactoryID)
|
||||
r.Header.Set("X-Key-Version", "1")
|
||||
r.Header.Set("X-Timestamp-Ms", strconv.FormatInt(timestamp, 10))
|
||||
r.Header.Set("X-Nonce", nonce)
|
||||
r.Header.Set("X-Request-ID", id)
|
||||
r.Header.Set("Authorization", "Device-HMAC-SHA256 "+RequestMAC(f.d.Key, "POST", path, f.d.FactoryID, 1, timestamp, nonce, id, raw))
|
||||
return r
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
f.s.RegisterRoutes(mux)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, request())
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, request())
|
||||
if w.Code != 401 {
|
||||
t.Fatal("nonce replay was accepted", w.Code)
|
||||
}
|
||||
r := request()
|
||||
r.TLS = nil
|
||||
r.Header.Set("X-Forwarded-Proto", "https")
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, r)
|
||||
if w.Code != 400 {
|
||||
t.Fatal("untrusted forwarded HTTPS accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func encode64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
type operator struct{ ID, PublicID, TenantID, Role string }
|
||||
|
||||
func operatorAccess(ctx context.Context, q queryer, principal identity.Principal, tenantPublicID string, write bool) (operator, error) {
|
||||
if !uuidValid(tenantPublicID) {
|
||||
return operator{}, errAccess
|
||||
}
|
||||
var a operator
|
||||
err := q.QueryRow(ctx, `SELECT ua.id::text,ua.public_id::text,t.id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' AND ua.account_kind='operator'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&a.ID, &a.PublicID, &a.TenantID, &a.Role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return a, errAccess
|
||||
}
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if write && a.Role != "ops_admin" && a.Role != "ops_operator" {
|
||||
return a, errAccess
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
type InventoryInput struct {
|
||||
FactoryMAC string `json:"factory_mac"`
|
||||
Model string `json:"hardware_model"`
|
||||
HardwareRevision string `json:"hardware_revision"`
|
||||
BatchNo string `json:"batch_no"`
|
||||
}
|
||||
|
||||
func (s *Service) CreateInventory(ctx context.Context, principal identity.Principal, tenant, requestID, idempotencyKey string, raw []byte, input InventoryInput) (map[string]any, error) {
|
||||
if len(idempotencyKey) < 16 || len(idempotencyKey) > 160 || !regexp.MustCompile(`^[0-9A-F]{12}$`).MatchString(input.FactoryMAC) || len(input.Model) < 1 || len(input.Model) > 32 || len(input.HardwareRevision) < 1 || len(input.HardwareRevision) > 16 || len(input.BatchNo) < 1 || len(input.BatchNo) > 120 {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "生产登记字段不正确")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
a, err := operatorAccess(ctx, tx, principal, tenant, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "inventory-request:"+a.TenantID+":"+a.ID+":"+idempotencyKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "inventory:"+input.FactoryMAC); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var jobID, deviceID, storedDigest string
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,device_id::text,request_digest FROM device_enrollment_job WHERE tenant_id=$1::uuid AND actor_id=$2::uuid AND request_key=$3`, a.TenantID, a.ID, idempotencyKey).Scan(&jobID, &deviceID, &storedDigest)
|
||||
if err == nil {
|
||||
if storedDigest != digest(raw) {
|
||||
return nil, fail(409, "REQUEST_BODY_CONFLICT", "生产请求号已用于其他内容")
|
||||
}
|
||||
d, e := readDevice(ctx, tx, deviceID, false)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return inventoryResult(d, jobID), tx.Commit(ctx)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
var existingTenant, model, revision string
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,tenant_id::text,model,COALESCE(hardware_revision,'') FROM device WHERE factory_mac=$1 FOR UPDATE`, input.FactoryMAC).Scan(&deviceID, &existingTenant, &model, &revision)
|
||||
var d Device
|
||||
if err == nil {
|
||||
if existingTenant != a.TenantID || model != input.Model || revision != input.HardwareRevision {
|
||||
return nil, fail(409, "INVENTORY_CONFLICT", "设备已有不同的生产或租户记录")
|
||||
}
|
||||
d, err = readDevice(ctx, tx, deviceID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A repair reuses the latest credential job. It never generates a key.
|
||||
err = tx.QueryRow(ctx, `SELECT id::text FROM device_enrollment_job WHERE device_id=$1::uuid ORDER BY created_at DESC,id DESC LIMIT 1`, deviceID).Scan(&jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inventoryResult(d, jobID), tx.Commit(ctx)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
d = Device{ID: uuid.NewString(), PublicID: uuid.NewString(), TenantID: a.TenantID, FactoryID: "KP01-" + input.FactoryMAC, Model: input.Model, HardwareRevision: input.HardwareRevision, Mode: "physical_confirm"}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device(id,public_id,tenant_id,serial_digest,model,hardware_revision,credential_ref,activation_state,activation_reason,enrollment_mode,factory_device_id,factory_mac) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,'FACTORY','controlled-production','physical_confirm',$8,$9)`, d.ID, d.PublicID, d.TenantID, digest([]byte(d.FactoryID)), d.Model, d.HardwareRevision, "onboarding://"+d.FactoryID, d.FactoryID, input.FactoryMAC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_twin(device_id,tenant_id,desired_config) VALUES($1::uuid,$2::uuid,'{"voice_runtime":"volc_hardware_agent"}'::jsonb)`, d.ID, d.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status) VALUES($1::uuid,$2::uuid,1,$3,'PENDING')`, d.ID, d.TenantID, s.crypt.seal(randomBytes(32), keyAAD(d.ID, 1)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = tx.QueryRow(ctx, `INSERT INTO device_enrollment_job(device_id,tenant_id,actor_id,request_key,request_digest,batch_no) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6) RETURNING id::text`, d.ID, d.TenantID, a.ID, idempotencyKey, digest(raw), input.BatchNo).Scan(&jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "device.inventory.create", requestID, map[string]any{"factory_device_id": d.FactoryID, "enrollment_job_id": jobID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inventoryResult(d, jobID), tx.Commit(ctx)
|
||||
}
|
||||
func inventoryResult(d Device, job string) map[string]any {
|
||||
return map[string]any{"device_id": d.FactoryID, "platform_device_id": d.PublicID, "enrollment_job_id": job, "contract_version": ContractVersion, "released": d.Released}
|
||||
}
|
||||
|
||||
type DeliveryInput struct {
|
||||
JobID string `json:"enrollment_job_id"`
|
||||
ReceiverKey string `json:"receiver_public_key_pem"`
|
||||
}
|
||||
|
||||
func (s *Service) DeliverCredential(ctx context.Context, p identity.Principal, tenant, publicID, requestID string, input DeliveryInput) (map[string]any, error) {
|
||||
if !uuidValid(publicID) || !uuidV4(input.JobID) {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "交付任务编号不正确")
|
||||
}
|
||||
block, _ := pem.Decode([]byte(input.ReceiverKey))
|
||||
if block == nil {
|
||||
return nil, fail(400, "INVALID_RECEIVER_KEY", "需要RSA公钥")
|
||||
}
|
||||
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fail(400, "INVALID_RECEIVER_KEY", "需要PKIX格式的RSA公钥")
|
||||
}
|
||||
key, ok := parsed.(*rsa.PublicKey)
|
||||
if !ok || key.N.BitLen() < 2048 || key.N.BitLen() > 4096 || key.E != 65537 {
|
||||
return nil, fail(400, "INVALID_RECEIVER_KEY", "需要2048至4096位RSA公钥")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
a, err := operatorAccess(ctx, tx, p, tenant, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var id string
|
||||
err = tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, publicID, a.TenantID).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, errAccess
|
||||
}
|
||||
d, err := readDevice(ctx, tx, id, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var version int64
|
||||
var storedKey, status string
|
||||
var delivered []byte
|
||||
var expiry *time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT key_version,COALESCE(receiver_key,''),delivery_ciphertext,delivery_expires_at,status FROM device_enrollment_job WHERE id=$1::uuid AND device_id=$2::uuid AND tenant_id=$3::uuid FOR UPDATE`, input.JobID, id, a.TenantID).Scan(&version, &storedKey, &delivered, &expiry, &status)
|
||||
if err != nil {
|
||||
return nil, errAccess
|
||||
}
|
||||
if storedKey != "" && storedKey != input.ReceiverKey {
|
||||
return nil, fail(409, "RECEIVER_CONFLICT", "同一交付任务不能更换接收方")
|
||||
}
|
||||
if status == "RELEASED" || status == "CANCELLED" {
|
||||
return nil, fail(409, "DELIVERY_CLOSED", "该交付任务已经关闭")
|
||||
}
|
||||
if len(delivered) > 0 && expiry != nil && expiry.After(s.now()) {
|
||||
var result map[string]any
|
||||
if err = json.Unmarshal(delivered, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, tx.Commit(ctx)
|
||||
}
|
||||
var encrypted []byte
|
||||
err = tx.QueryRow(ctx, `SELECT ciphertext FROM device_onboarding_key WHERE device_id=$1::uuid AND key_version=$2 AND status='PENDING'`, id, version).Scan(&encrypted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := s.crypt.open(encrypted, keyAAD(id, version))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Envelope encryption supports a full manufacturing payload independently
|
||||
// of the RSA OAEP plaintext size. The receiver unwraps a random AES key.
|
||||
envelopeKey := randomBytes(32)
|
||||
ec, _ := newCryptor(fmtHex(envelopeKey))
|
||||
until := s.now().Add(15 * time.Minute)
|
||||
plain, _ := json.Marshal(map[string]any{"device_id": d.FactoryID, "platform_device_id": d.PublicID, "device_key_b64": encode64(secret), "key_version": version, "contract_version": ContractVersion, "expires_at_ms": until.UnixMilli()})
|
||||
wrapped, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, key, envelopeKey, []byte(input.JobID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]any{"algorithm": "RSA-OAEP-SHA256+A256GCM", "enrollment_job_id": input.JobID, "wrapped_key_b64": encode64(wrapped), "ciphertext_b64": encode64(ec.seal(plain, input.JobID)), "expires_at_ms": until.UnixMilli()}
|
||||
saved, _ := json.Marshal(result)
|
||||
_, err = tx.Exec(ctx, `UPDATE device_enrollment_job SET receiver_key=$2,delivery_ciphertext=$3,delivery_expires_at=$4,status='DELIVERED' WHERE id=$1::uuid`, input.JobID, input.ReceiverKey, saved, until)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "device.credential.deliver", requestID, map[string]any{"job_id": input.JobID, "key_version": version}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, tx.Commit(ctx)
|
||||
}
|
||||
func fmtHex(b []byte) string {
|
||||
const alphabet = "0123456789abcdef"
|
||||
out := make([]byte, len(b)*2)
|
||||
for i, v := range b {
|
||||
out[i*2] = alphabet[v>>4]
|
||||
out[i*2+1] = alphabet[v&15]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
type ReleaseInput struct {
|
||||
JobID string `json:"enrollment_job_id"`
|
||||
Evidence string `json:"evidence_ref"`
|
||||
ExpectedVersion int64 `json:"expected_key_version"`
|
||||
}
|
||||
|
||||
func (s *Service) ReleaseInventory(ctx context.Context, p identity.Principal, tenant, publicID, requestID string, in ReleaseInput) (map[string]any, error) {
|
||||
if !uuidValid(publicID) || !uuidV4(in.JobID) || len(strings.TrimSpace(in.Evidence)) < 3 || len(in.Evidence) > 500 || in.ExpectedVersion < 1 {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "需要烧录校验记录和凭据版本")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
a, err := operatorAccess(ctx, tx, p, tenant, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var id string
|
||||
err = tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND enrollment_mode='physical_confirm' AND status='registered' AND activation_state NOT IN ('LOST','REVOKED') FOR UPDATE`, publicID, a.TenantID).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, errAccess
|
||||
}
|
||||
d, err := readDevice(ctx, tx, id, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var status string
|
||||
var version int64
|
||||
err = tx.QueryRow(ctx, `SELECT status,key_version FROM device_enrollment_job WHERE id=$1::uuid AND device_id=$2::uuid FOR UPDATE`, in.JobID, id).Scan(&status, &version)
|
||||
if err != nil {
|
||||
return nil, errAccess
|
||||
}
|
||||
if version != in.ExpectedVersion || (status != "DELIVERED" && status != "RELEASED") {
|
||||
return nil, fail(409, "ENROLLMENT_NOT_DELIVERED", "请先完成对应版本凭据交付")
|
||||
}
|
||||
if status == "RELEASED" {
|
||||
return inventoryResult(d, in.JobID), tx.Commit(ctx)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_onboarding_key SET status='REVOKED' WHERE device_id=$1::uuid AND status='ACTIVE'`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_onboarding_key SET status='ACTIVE',activated_at=now() WHERE device_id=$1::uuid AND key_version=$2 AND status='PENDING'`, id, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.Lifecycle == "FACTORY" {
|
||||
if err = requireLifecycle(ctx, tx, d, deviceactivation.Claimable, requestID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device SET onboarding_released_at=COALESCE(onboarding_released_at,now()),onboarding_revision=onboarding_revision+1,version=version+1,updated_at=now() WHERE id=$1::uuid`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_enrollment_job SET status='RELEASED',evidence_ref=$2,delivery_ciphertext=NULL WHERE id=$1::uuid`, in.JobID, in.Evidence)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "device.inventory.release", requestID, map[string]any{"job_id": in.JobID, "key_version": version, "evidence_ref": in.Evidence}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Released = true
|
||||
return inventoryResult(d, in.JobID), tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"github.com/google/uuid"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIntegrationCredentialRotationAndReboot(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.pending()
|
||||
rotation := RotationInput{Reason: "test rotation", ExpectedVersion: 1}
|
||||
req := uuid.NewString()
|
||||
rotated, err := f.s.Rotate(f.ctx, f.p, f.tenantPublic, f.d.PublicID, req, req, requestJSON(rotation), rotation)
|
||||
must(t, err)
|
||||
_, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 2)
|
||||
if err == nil {
|
||||
t.Fatal("pending key became active")
|
||||
}
|
||||
receiver, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
must(t, err)
|
||||
pub, err := x509.MarshalPKIXPublicKey(&receiver.PublicKey)
|
||||
must(t, err)
|
||||
job := rotated["enrollment_job_id"].(string)
|
||||
input := DeliveryInput{JobID: job, ReceiverKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub}))}
|
||||
first, err := f.s.DeliverCredential(f.ctx, f.p, f.tenantPublic, f.d.PublicID, uuid.NewString(), input)
|
||||
must(t, err)
|
||||
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_enrollment_job SET delivery_expires_at=now()-interval '1 second' WHERE id=$1::uuid`, job)
|
||||
must(t, err)
|
||||
renewed, err := f.s.DeliverCredential(f.ctx, f.p, f.tenantPublic, f.d.PublicID, uuid.NewString(), input)
|
||||
must(t, err)
|
||||
if first["ciphertext_b64"] == renewed["ciphertext_b64"] {
|
||||
t.Fatal("expired envelope reused")
|
||||
}
|
||||
release := ReleaseInput{JobID: job, ExpectedVersion: 2, Evidence: "fixture readback"}
|
||||
_, err = f.s.ReleaseInventory(f.ctx, f.p, f.tenantPublic, f.d.PublicID, uuid.NewString(), release)
|
||||
must(t, err)
|
||||
_, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
|
||||
if err == nil {
|
||||
t.Fatal("old key still active")
|
||||
}
|
||||
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 2)
|
||||
must(t, err)
|
||||
boot := uuid.NewString()
|
||||
challenge, err := f.s.TimeChallenge(f.ctx, TimeChallengeInput{DeviceID: f.d.FactoryID, KeyVersion: 2, BootID: boot, ClientNonce: randomToken(16)})
|
||||
must(t, err)
|
||||
raw, err := base64.RawURLEncoding.DecodeString(challenge["payload_b64"].(string))
|
||||
must(t, err)
|
||||
var packet map[string]any
|
||||
must(t, json.Unmarshal(raw, &packet))
|
||||
reg := RegisterInput{ChallengeID: packet["challenge_id"].(string), BootID: boot, Model: f.d.Model, HardwareRevision: f.d.HardwareRevision, FirmwareVersion: "1.2", SDKVersion: "1.0"}
|
||||
_, err = f.s.Register(f.ctx, f.d, uuid.NewString(), requestJSON(reg), reg)
|
||||
must(t, err)
|
||||
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 2)
|
||||
must(t, err)
|
||||
_, err = f.s.Confirm(f.ctx, f.d, uuid.NewString(), requestJSON(f.confirm), f.confirm)
|
||||
if err == nil {
|
||||
t.Fatal("pre-reboot confirmation accepted")
|
||||
}
|
||||
}
|
||||
func TestIntegrationRegistrationSurvivesRebindAndRejectsOldResult(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.bound()
|
||||
j := f.registerSDK()
|
||||
var status string
|
||||
_, err := f.s.pool.Exec(f.ctx, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID)
|
||||
must(t, err)
|
||||
current, err := f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
|
||||
must(t, err)
|
||||
late := ResultInput{ActivationID: j.ID, AttemptID: j.AttemptID, Stage: "SDK_REGISTER", Outcome: "SUCCESS_OBSERVED"}
|
||||
_, err = f.s.ActivationResult(f.ctx, current, uuid.NewString(), requestJSON(late), late)
|
||||
if err == nil {
|
||||
t.Fatal("old binding result was accepted")
|
||||
}
|
||||
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
|
||||
must(t, err)
|
||||
setupIn := SetupInput{BootID: f.d.BootID, LocalSetupID: uuid.NewString(), Transport: "LOCAL_WEB"}
|
||||
setup, err := f.s.Setup(f.ctx, f.d, uuid.NewString(), requestJSON(setupIn), setupIn)
|
||||
must(t, err)
|
||||
f.setup = setup["setup_id"].(string)
|
||||
verifier := randomToken(32)
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
handoffIn := HandoffInput{SetupID: f.setup, LocalID: setupIn.LocalSetupID, Challenge: encode64(sum[:])}
|
||||
handoff, err := f.s.Handoff(f.ctx, f.d, uuid.NewString(), requestJSON(handoffIn), handoffIn)
|
||||
must(t, err)
|
||||
browser, err := f.s.Exchange(f.ctx, ExchangeInput{Token: handoff["handoff_token"].(string), Verifier: verifier})
|
||||
must(t, err)
|
||||
f.web, err = f.s.webSession(f.ctx, f.s.pool, digest([]byte(browser.Token)))
|
||||
must(t, err)
|
||||
browser, err = f.s.AuthLink(f.ctx, f.web, f.user)
|
||||
must(t, err)
|
||||
f.web, err = f.s.webSession(f.ctx, f.s.pool, digest([]byte(browser.Token)))
|
||||
must(t, err)
|
||||
f.bound()
|
||||
must(t, f.s.ProcessActivation(f.ctx, f.event()))
|
||||
if f.registrar.calls.Load() != 0 {
|
||||
t.Fatal("rebind repeated supplier registration")
|
||||
}
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT status FROM device_activation_job WHERE device_id=$1::uuid`, f.d.ID).Scan(&status))
|
||||
if status != "REGISTERED_OBSERVED" {
|
||||
t.Fatal("rebind did not resume activation")
|
||||
}
|
||||
}
|
||||
func TestIntegrationCloseRequiresDurableACKAndGrace(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.bound()
|
||||
poll, err := f.s.Poll(f.ctx, f.d, PollInput{SetupID: f.setup, BootID: f.d.BootID})
|
||||
must(t, err)
|
||||
st := poll["state"].(State)
|
||||
ack := StateAckInput{Revision: st.Revision, Generation: st.Generation, Hash: st.Hash, LocalGeneration: 1}
|
||||
_, err = f.s.StateAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
|
||||
must(t, err)
|
||||
var saved time.Time
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT accepted_at FROM device_state_ack WHERE device_id=$1::uuid`, f.d.ID).Scan(&saved))
|
||||
_, err = f.s.StateAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
|
||||
must(t, err)
|
||||
var again time.Time
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT accepted_at FROM device_state_ack WHERE device_id=$1::uuid`, f.d.ID).Scan(&again))
|
||||
if !again.Equal(saved) {
|
||||
t.Fatal("ACK retry extended close grace")
|
||||
}
|
||||
closeIn := CloseInput{SetupID: f.setup, Reason: "SUCCESS", Revision: st.Revision}
|
||||
_, err = f.s.Close(f.ctx, f.d, uuid.NewString(), requestJSON(closeIn), closeIn)
|
||||
if err == nil {
|
||||
t.Fatal("closed before 30 second grace")
|
||||
}
|
||||
var binding string
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT public_id::text FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID).Scan(&binding))
|
||||
webAck := WebAckInput{BindingID: binding, Generation: st.Generation, Revision: st.Revision}
|
||||
_, err = f.s.WebAck(f.ctx, f.web, uuid.NewString(), requestJSON(webAck), webAck)
|
||||
must(t, err)
|
||||
f.s.now = func() time.Time { return time.Now().Add(4 * time.Second) }
|
||||
_, err = f.s.Close(f.ctx, f.d, uuid.NewString(), requestJSON(closeIn), closeIn)
|
||||
must(t, err)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package onboarding
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s *Service) accountSessionHTTP(r *http.Request) (any, error) {
|
||||
if r.Header.Get("Sec-Fetch-Site") == "cross-site" || r.URL.RawQuery != "" {
|
||||
return nil, errAccess
|
||||
}
|
||||
cookie, err := r.Cookie(s.cookieName(true))
|
||||
if err != nil {
|
||||
return nil, fail(401, "LOGIN_REQUIRED", "请先登录")
|
||||
}
|
||||
p, err := s.AccountPrincipal(r.Context(), cookie.Value, "", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := randomToken(32)
|
||||
_, err = s.pool.Exec(r.Context(), `UPDATE onboarding_account_session SET csrf_hash=$2 WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now()`, digest([]byte(cookie.Value)), digest([]byte(token)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c, e := r.Cookie(s.cookieName(false)); e == nil && validB64(c.Value, 32) {
|
||||
_, err = s.pool.Exec(r.Context(), `UPDATE onboarding_web_session SET csrf_hash=$2 WHERE token_hash=$1 AND issuer=$3 AND subject=$4 AND revoked_at IS NULL AND expires_at>now()`, digest([]byte(c.Value)), digest([]byte(token)), p.Issuer, p.Subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return map[string]any{"csrf_token": token, "authenticated": true}, nil
|
||||
}
|
||||
func (s *Service) logoutHTTP(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
if !s.sameOrigin(r) {
|
||||
return nil, errAccess
|
||||
}
|
||||
raw, err := readBody(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = StrictJSON(raw, &struct{}{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Expired cookies can be cleared without treating them as a live login.
|
||||
for _, account := range []bool{true, false} {
|
||||
c, e := r.Cookie(s.cookieName(account))
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
table := "onboarding_web_session"
|
||||
if account {
|
||||
table = "onboarding_account_session"
|
||||
}
|
||||
var live bool
|
||||
err = s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM `+table+` WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now())`, digest([]byte(c.Value))).Scan(&live)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if live {
|
||||
var valid bool
|
||||
err = s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM `+table+` WHERE token_hash=$1 AND csrf_hash=$2)`, digest([]byte(c.Value)), digest([]byte(r.Header.Get("X-CSRF-Token")))).Scan(&valid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !valid {
|
||||
return nil, errAccess
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, account := range []bool{true, false} {
|
||||
c, e := r.Cookie(s.cookieName(account))
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
table := "onboarding_web_session"
|
||||
if account {
|
||||
table = "onboarding_account_session"
|
||||
}
|
||||
if _, err = s.pool.Exec(r.Context(), `UPDATE `+table+` SET revoked_at=COALESCE(revoked_at,now()) WHERE token_hash=$1`, digest([]byte(c.Value))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, account := range []bool{false, true} {
|
||||
http.SetCookie(w, &http.Cookie{Name: s.cookieName(account), Value: "", Path: "/", HttpOnly: true, Secure: !s.options.AllowLocalHTTP, SameSite: http.SameSiteLaxMode, MaxAge: -1})
|
||||
}
|
||||
return map[string]any{"signed_out": true}, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"encoding/json"
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Recover durable jobs after an outbox retry budget was exhausted during a
|
||||
// pause. ProcessActivation owns the device lock and the no-repeat fence.
|
||||
func (s *Service) RunMaintenance(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := s.MaintainOnce(ctx); err != nil && ctx.Err() == nil {
|
||||
s.options.Logger.Error("onboarding recovery scan failed")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
func (s *Service) MaintainOnce(ctx context.Context) error {
|
||||
rows, err := s.pool.Query(ctx, `SELECT j.device_id::text,j.id::text,j.binding_generation FROM device_activation_job j JOIN device d ON d.id=j.device_id JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL AND b.generation=j.binding_generation WHERE d.status='registered' AND d.activation_state NOT IN ('LOST','REVOKED') AND j.binding_generation=d.binding_generation AND j.updated_at<now()-interval '3 minutes' AND (j.status IN ('NOT_STARTED','RESERVED','CONTACT_POSSIBLE') OR (j.status='REGISTERED_OBSERVED' AND j.config_ciphertext IS NULL)) ORDER BY j.updated_at LIMIT 10`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type item struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ActivationID string `json:"activation_id"`
|
||||
Generation int64 `json:"binding_generation"`
|
||||
}
|
||||
var items []item
|
||||
for rows.Next() {
|
||||
var v item
|
||||
if err = rows.Scan(&v.DeviceID, &v.ActivationID, &v.Generation); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
items = append(items, v)
|
||||
}
|
||||
rows.Close()
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.options.ActivationEnabled {
|
||||
for _, v := range items {
|
||||
raw, _ := json.Marshal(v)
|
||||
if err = s.ProcessActivation(ctx, outbox.Event{ID: uuid.NewString(), Payload: raw}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// Retain business attempts and idempotency tombstones for replay protection.
|
||||
_, err = s.pool.Exec(ctx, `DELETE FROM device_handoff WHERE expires_at<now()-interval '1 day'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `DELETE FROM device_time_challenge WHERE expires_at<now()-interval '1 day'`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type RotationInput struct {
|
||||
Reason string `json:"reason"`
|
||||
ExpectedVersion int64 `json:"expected_key_version"`
|
||||
}
|
||||
|
||||
func (s *Service) Rotate(ctx context.Context, p identity.Principal, tenant, device, idempotencyKey, requestID string, raw []byte, in RotationInput) (map[string]any, error) {
|
||||
if !uuidValid(device) || !nonempty(in.Reason, 200) || in.ExpectedVersion < 1 || len(idempotencyKey) < 16 || len(idempotencyKey) > 160 {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "密钥轮换参数不正确")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
a, err := operatorAccess(ctx, tx, p, tenant, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var internal string
|
||||
err = tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid`, device, a.TenantID).Scan(&internal)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errAccess
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := readDevice(ctx, tx, internal, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !deviceAllowed(d) {
|
||||
return nil, errAccess
|
||||
}
|
||||
var job, stored string
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,request_digest FROM device_enrollment_job WHERE device_id=$1::uuid AND actor_id=$2::uuid AND request_key=$3`, d.ID, a.ID, idempotencyKey).Scan(&job, &stored)
|
||||
if err == nil {
|
||||
if stored != digest(raw) {
|
||||
return nil, fail(409, "REQUEST_BODY_CONFLICT", "轮换请求号已用于其他内容")
|
||||
}
|
||||
return inventoryResult(d, job), tx.Commit(ctx)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
var active, latest int64
|
||||
var pending bool
|
||||
err = tx.QueryRow(ctx, `SELECT COALESCE(max(key_version) FILTER(WHERE status='ACTIVE'),0),max(key_version),bool_or(status='PENDING') FROM device_onboarding_key WHERE device_id=$1::uuid`, d.ID).Scan(&active, &latest, &pending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active != in.ExpectedVersion {
|
||||
return nil, fail(409, "KEY_VERSION_CONFLICT", "当前凭据版本已变化")
|
||||
}
|
||||
if pending {
|
||||
return nil, fail(409, "DELIVERY_PENDING", "已有凭据交付任务待完成")
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status) VALUES($1::uuid,$2::uuid,$3,$4,'PENDING')`, d.ID, d.TenantID, latest+1, s.crypt.seal(randomBytes(32), keyAAD(d.ID, latest+1)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = tx.QueryRow(ctx, `INSERT INTO device_enrollment_job(tenant_id,device_id,actor_id,request_key,request_digest,batch_no,key_version) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,'credential-rotation',$6) RETURNING id::text`, d.TenantID, d.ID, a.ID, idempotencyKey, digest(raw), latest+1).Scan(&job)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "onboarding.key.rotation.prepare", requestID, map[string]any{"job_id": job, "key_version": latest + 1, "reason": in.Reason}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := inventoryResult(d, job)
|
||||
result["key_version"] = latest + 1
|
||||
return result, tx.Commit(ctx)
|
||||
}
|
||||
func (s *Service) OpsList(ctx context.Context, p identity.Principal, tenant string) (map[string]any, error) {
|
||||
a, err := operatorAccess(ctx, s.pool, p, tenant, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT d.public_id::text,d.factory_device_id,d.model,d.binding_generation,d.onboarding_revision,d.onboarding_released_at IS NOT NULL,COALESCE(j.id::text,''),COALESCE(j.status,'NOT_STARTED'),COALESCE(j.provider_device_name,''),COALESCE(j.last_error_code,''),COALESCE(j.version,0),COALESCE((SELECT a.id::text FROM device_activation_attempt a WHERE a.activation_id=j.id AND a.executor='DEVICE' AND a.status IN ('CONTACT_POSSIBLE','UNKNOWN') ORDER BY a.created_at DESC LIMIT 1),j.current_attempt_id::text,''),COALESCE((SELECT max(key_version) FROM device_onboarding_key k WHERE k.device_id=d.id AND k.status='PENDING'),0),COALESCE((SELECT max(key_version) FROM device_onboarding_key k WHERE k.device_id=d.id AND k.status='ACTIVE'),0),COALESCE((SELECT ej.id::text FROM device_enrollment_job ej WHERE ej.device_id=d.id ORDER BY created_at DESC LIMIT 1),'') FROM device d LEFT JOIN device_activation_job j ON j.device_id=d.id WHERE d.tenant_id=$1::uuid AND d.enrollment_mode='physical_confirm' ORDER BY d.created_at DESC LIMIT 200`, a.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, factory, model, job, status, name, code, enrollment, attempt string
|
||||
var gen, rev, ver, key, pendingKey int64
|
||||
var released bool
|
||||
if err = rows.Scan(&id, &factory, &model, &gen, &rev, &released, &job, &status, &name, &code, &ver, &attempt, &pendingKey, &key, &enrollment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, map[string]any{"platform_device_id": id, "device_id": factory, "model": model, "binding_generation": gen, "device_revision": rev, "released": released, "activation_id": job, "activation_state": status, "provider_device_name": name, "last_error_code": code, "activation_version": ver, "key_version": key, "pending_key_version": pendingKey, "current_attempt_id": attempt, "enrollment_job_id": enrollment})
|
||||
}
|
||||
return map[string]any{"items": items, "limit": 200}, rows.Err()
|
||||
}
|
||||
|
||||
type ReconcileInput struct {
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
EvidenceRef string `json:"evidence_ref"`
|
||||
Reason string `json:"reason"`
|
||||
AttemptID string `json:"attempt_id,omitempty"`
|
||||
DeviceSecret string `json:"device_secret,omitempty"`
|
||||
RTCAppID string `json:"rtc_app_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) Reconcile(ctx context.Context, p identity.Principal, tenant, activationID, requestID string, in ReconcileInput) (map[string]any, error) {
|
||||
if !uuidV4(activationID) || in.ExpectedVersion < 1 || !nonempty(in.EvidenceRef, 500) || !nonempty(in.Reason, 200) || !oneOf(in.Conclusion, "OBSERVED_REGISTERED", "CONFIRMED_NOT_REGISTERED", "DEVICE_ATTEMPT_STOPPED") {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "对账结论、版本和证据必填")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
a, err := operatorAccess(ctx, tx, p, tenant, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Role != "ops_admin" {
|
||||
return nil, errAccess
|
||||
}
|
||||
var device string
|
||||
err = tx.QueryRow(ctx, `SELECT device_id::text FROM device_activation_job WHERE id=$1::uuid AND tenant_id=$2::uuid`, activationID, a.TenantID).Scan(&device)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errAccess
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := readDevice(ctx, tx, device, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
j, err := readJob(ctx, tx, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if j.Version != in.ExpectedVersion {
|
||||
return nil, fail(409, "ACTIVATION_VERSION_CONFLICT", "任务已变化,请刷新后核对")
|
||||
}
|
||||
if in.Conclusion == "DEVICE_ATTEMPT_STOPPED" {
|
||||
if !uuidV4(in.AttemptID) {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "请指定设备尝试")
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `UPDATE device_activation_attempt SET status='DEFINITE_FAILURE',completed_at=now(),evidence=jsonb_build_object('operator_evidence',$3::text) WHERE id=$1::uuid AND activation_id=$2::uuid AND executor='DEVICE' AND stage='CONVERSATION_TEST' AND status IN ('CONTACT_POSSIBLE','UNKNOWN') AND permit_expires_at<now()-interval '3 minutes'`, in.AttemptID, j.ID, in.EvidenceRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return nil, fail(409, "ATTEMPT_STILL_POSSIBLE", "许可尚未超过安全等待期,或已确认结果")
|
||||
}
|
||||
} else {
|
||||
if !oneOf(j.Status, "CONTACT_POSSIBLE", "RECONCILE_REQUIRED", "BLOCKED") || len(j.Secret) > 0 {
|
||||
return nil, errState
|
||||
}
|
||||
var attempt string
|
||||
var ended bool
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,permit_expires_at<now()-interval '3 minutes' FROM device_activation_attempt WHERE activation_id=$1::uuid AND (executor='SERVER' OR stage='SDK_REGISTER') ORDER BY created_at DESC LIMIT 1 FOR UPDATE`, j.ID).Scan(&attempt, &ended)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ended {
|
||||
return nil, fail(409, "ATTEMPT_STILL_POSSIBLE", "供应商调用尚未超过安全等待期")
|
||||
}
|
||||
if in.Conclusion == "OBSERVED_REGISTERED" {
|
||||
if (in.DeviceSecret != "" && len(in.DeviceSecret) < 16) || len(in.DeviceSecret) > 4096 || len(in.RTCAppID) > 200 {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "请提供核验后的设备凭据")
|
||||
}
|
||||
if in.DeviceSecret != "" {
|
||||
j.Secret = s.crypt.seal([]byte(in.DeviceSecret), "provider:"+j.ID)
|
||||
}
|
||||
observed := s.now()
|
||||
j.Registered = &observed
|
||||
j.RTCAppID = in.RTCAppID
|
||||
status := "BLOCKED"
|
||||
var bound bool
|
||||
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL AND generation=$2)`, d.ID, d.Generation).Scan(&bound); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bound && deviceAllowed(d) && j.Generation == d.Generation {
|
||||
status = "REGISTERED_OBSERVED"
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET provider_secret_ciphertext=$2,provider_rtc_app_id=$3,status=$4,sdk_registered_at=COALESCE(sdk_registered_at,now()),engine_ready_at=now() WHERE id=$1::uuid`, j.ID, j.Secret, j.RTCAppID, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='SUCCESS_OBSERVED',completed_at=now(),evidence=jsonb_build_object('operator_evidence',$2::text) WHERE id=$1::uuid`, attempt, in.EvidenceRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
j.Status = status
|
||||
if status == "REGISTERED_OBSERVED" {
|
||||
if d.Lifecycle == "BOUND" {
|
||||
if err = requireLifecycle(ctx, tx, d, deviceactivation.Provisioning, requestID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err = s.ensureConfig(ctx, tx, d, &j); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='DEFINITE_FAILURE',completed_at=now(),evidence=jsonb_build_object('operator_evidence',$2::text) WHERE id=$1::uuid`, attempt, in.EvidenceRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='NOT_STARTED',current_attempt_id=NULL WHERE id=$1::uuid`, j.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dedup := "reconcile:" + requestID
|
||||
payload, _ := json.Marshal(map[string]any{"device_id": d.ID, "activation_id": j.ID, "binding_generation": d.Generation})
|
||||
_, err = s.outbox.Enqueue(ctx, tx, outbox.NewEvent{TenantID: &d.TenantID, AggregateType: "device", AggregateID: d.ID, EventType: ActivationEvent, DeduplicationKey: &dedup, Payload: payload})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET version=version+1,reconciliation_evidence=$2,last_error_code=NULL,status=CASE WHEN $3='DEVICE_ATTEMPT_STOPPED' THEN 'REGISTERED_OBSERVED' ELSE status END,updated_at=now() WHERE id=$1::uuid`, j.ID, in.EvidenceRef, in.Conclusion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = bump(ctx, tx, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "onboarding.activation.reconcile", requestID, map[string]any{"activation_id": j.ID, "conclusion": in.Conclusion, "evidence_ref": in.EvidenceRef, "reason": in.Reason}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := s.state(ctx, tx, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"state": st}, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// This status route remains available after the temporary setup closes.
|
||||
func (s *Service) AccountDevices(ctx context.Context, p identity.Principal) (map[string]any, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT d.public_id::text,d.factory_device_id,d.binding_generation,d.onboarding_revision,COALESCE(j.status,'NOT_STARTED'),h.name,sp.preferred_name FROM device d JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL JOIN household h ON h.id=b.household_id AND h.status='active' JOIN subject_profile sp ON sp.id=b.subject_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN tenant t ON t.id=d.tenant_id AND t.status='active' LEFT JOIN device_activation_job j ON j.device_id=d.id WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND d.enrollment_mode='physical_confirm' ORDER BY d.registered_at DESC LIMIT 200`, p.Issuer, p.Subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, factory, status, household, name string
|
||||
var gen, rev int64
|
||||
if err = rows.Scan(&id, &factory, &gen, &rev, &status, &household, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, map[string]any{"platform_device_id": id, "device_id": factory, "binding_generation": gen, "device_revision": rev, "activation_state": status, "household_name": household, "preferred_name": name})
|
||||
}
|
||||
return map[string]any{"items": items}, rows.Err()
|
||||
}
|
||||
|
||||
// Recovered settings never reset ownership; the regular authorized unbind
|
||||
// route must first close the binding, then firmware may clear local user data.
|
||||
func requireLifecycle(ctx context.Context, tx pgx.Tx, d Device, target deviceactivation.State, requestID string) error {
|
||||
_, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: d.ID, TenantID: d.TenantID, Target: target, Reason: "首次接入状态同步", Source: "onboarding", ActorType: "system", RequestID: requestID})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"github.com/google/uuid"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIntegrationClaimReplayCannotReturnReplacement(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
in := ClaimInput{ElderID: f.elder, Generation: f.d.Generation}
|
||||
id := uuid.NewString()
|
||||
first, err := f.s.Claim(f.ctx, f.web, id, requestJSON(in), in)
|
||||
must(t, err)
|
||||
cancel := CancelInput{ClaimID: first["claim_id"].(string)}
|
||||
_, err = f.s.Cancel(f.ctx, f.web, uuid.NewString(), requestJSON(cancel), cancel)
|
||||
must(t, err)
|
||||
_, err = f.s.Claim(f.ctx, f.web, uuid.NewString(), requestJSON(in), in)
|
||||
must(t, err)
|
||||
_, err = f.s.Claim(f.ctx, f.web, id, requestJSON(in), in)
|
||||
if err == nil {
|
||||
t.Fatal("returned cancelled claim from cache")
|
||||
}
|
||||
}
|
||||
func TestIntegrationConsumedHandoffCannotReplayReplacement(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
var local string
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT local_setup_id::text FROM device_setup_session WHERE id=$1::uuid`, f.setup).Scan(&local))
|
||||
verifier := randomToken(32)
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
in := HandoffInput{SetupID: f.setup, LocalID: local, Challenge: encode64(sum[:])}
|
||||
id := uuid.NewString()
|
||||
first, err := f.s.Handoff(f.ctx, f.d, id, requestJSON(in), in)
|
||||
must(t, err)
|
||||
_, err = f.s.Exchange(f.ctx, ExchangeInput{Token: first["handoff_token"].(string), Verifier: verifier})
|
||||
must(t, err)
|
||||
_, err = f.s.Handoff(f.ctx, f.d, uuid.NewString(), requestJSON(in), in)
|
||||
must(t, err)
|
||||
_, err = f.s.Handoff(f.ctx, f.d, id, requestJSON(in), in)
|
||||
if err == nil {
|
||||
t.Fatal("returned consumed handoff")
|
||||
}
|
||||
}
|
||||
|
||||
type captureSMS struct {
|
||||
code string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *captureSMS) Send(_ context.Context, _ string, code string, _ string) error {
|
||||
p.code = code
|
||||
p.calls++
|
||||
return nil
|
||||
}
|
||||
func TestIntegrationSMSLoginRateAndOneTimeCode(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
sender := &captureSMS{}
|
||||
f.s.options.SMS = sender
|
||||
f.s.options.SMSLoginEnabled = true
|
||||
// Start a fresh anonymous browser session, as a phone opened from the device.
|
||||
_, err := f.s.pool.Exec(f.ctx, `UPDATE onboarding_web_session SET user_id=NULL,issuer=NULL,subject=NULL WHERE token_hash=$1`, f.web.Hash)
|
||||
must(t, err)
|
||||
f.web.UserID = ""
|
||||
f.web.Issuer = ""
|
||||
f.web.Subject = ""
|
||||
phone := "+8613" + strings.ReplaceAll(uuid.NewString(), "-", "")[:9]
|
||||
// Use decimal digits while keeping independent fixtures unique.
|
||||
for i, c := range phone {
|
||||
if c >= 'a' && c <= 'f' {
|
||||
phone = phone[:i] + string('0'+(c-'a')) + phone[i+1:]
|
||||
}
|
||||
}
|
||||
in := SMSSendInput{Phone: phone, Purpose: "LOGIN"}
|
||||
id := uuid.NewString()
|
||||
sent, err := f.s.SendSMS(f.ctx, f.web, id, in)
|
||||
must(t, err)
|
||||
_, err = f.s.SendSMS(f.ctx, f.web, id, in)
|
||||
must(t, err)
|
||||
if sender.calls != 1 {
|
||||
t.Fatal("SMS retry sent twice")
|
||||
}
|
||||
_, err = f.s.SendSMS(f.ctx, f.web, uuid.NewString(), in)
|
||||
if err == nil {
|
||||
t.Fatal("resend cooldown ignored")
|
||||
}
|
||||
verify := SMSVerifyInput{ChallengeID: sent["sms_challenge_id"].(string), Code: sender.code}
|
||||
bad := verify
|
||||
bad.Code = "000000"
|
||||
if bad.Code == sender.code {
|
||||
bad.Code = "999999"
|
||||
}
|
||||
_, err = f.s.VerifySMS(f.ctx, f.web, bad)
|
||||
if err == nil {
|
||||
t.Fatal("incorrect SMS accepted")
|
||||
}
|
||||
credentials, err := f.s.VerifySMS(f.ctx, f.web, verify)
|
||||
must(t, err)
|
||||
principal, err := f.s.AccountPrincipal(f.ctx, credentials.AccountToken, credentials.CSRF, true)
|
||||
must(t, err)
|
||||
if principal.Issuer != "urn:platform:onboarding:sms" {
|
||||
t.Fatal("incorrect SMS identity namespace")
|
||||
}
|
||||
_, err = f.s.VerifySMS(f.ctx, f.web, verify)
|
||||
if err == nil {
|
||||
t.Fatal("SMS consumed twice")
|
||||
}
|
||||
}
|
||||
func TestHTTPSMSGatewayContract(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" || r.Header.Get("Idempotency-Key") != "test-request" {
|
||||
t.Error("gateway authentication missing")
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if body["code"] != "123456" || body["phone_e164"] != "+8613800000000" {
|
||||
t.Error("gateway payload incorrect")
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}))
|
||||
defer server.Close()
|
||||
provider, err := NewHTTPSMSProvider(server.URL, "test-token")
|
||||
must(t, err)
|
||||
provider.(*HTTPSMSProvider).client = server.Client()
|
||||
must(t, provider.Send(context.Background(), "+8613800000000", "123456", "test-request"))
|
||||
if calls != 1 {
|
||||
t.Fatal("unexpected request count")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) replayGuard(ctx context.Context, tx pgx.Tx, d Device, actor, scope string, raw []byte) error {
|
||||
if strings.HasPrefix(actor, "web:") {
|
||||
if err := s.checkWebActor(ctx, tx, strings.TrimPrefix(actor, "web:"), d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch scope {
|
||||
case "register":
|
||||
var in RegisterInput
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
if d.BootID != in.BootID {
|
||||
return errState
|
||||
}
|
||||
case "setup":
|
||||
var in SetupInput
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
var valid bool
|
||||
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_setup_session WHERE device_id=$1::uuid AND boot_id=$2::uuid AND local_setup_id=$3::uuid AND binding_generation=$4 AND closed_at IS NULL AND expires_at>now())`, d.ID, in.BootID, in.LocalSetupID, d.Generation).Scan(&valid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return fail(410, "SETUP_EXPIRED", "请创建新的恢复会话")
|
||||
}
|
||||
case "handoff":
|
||||
var in HandoffInput
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
if _, err := readSetup(ctx, tx, in.SetupID, d, true); err != nil {
|
||||
return err
|
||||
}
|
||||
var valid bool
|
||||
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_handoff WHERE setup_id=$1::uuid AND code_challenge=$2 AND consumed_at IS NULL AND expires_at>now())`, in.SetupID, in.Challenge).Scan(&valid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return fail(410, "HANDOFF_INVALID", "入口已消费或失效,请重新获取")
|
||||
}
|
||||
case "confirm":
|
||||
var in ConfirmationInput
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
c, err := readClaim(ctx, tx, in.ClaimID, d.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = claimPermission(ctx, tx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Status != "CONFIRMED" || c.PressID != in.PressEventID || d.Generation != c.Generation+1 {
|
||||
return errState
|
||||
}
|
||||
var valid bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE id=$1::uuid AND generation=$2 AND unbound_at IS NULL)`, c.BindingID, d.Generation).Scan(&valid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return errState
|
||||
}
|
||||
case "claim":
|
||||
var in ClaimInput
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
w, err := s.webSession(ctx, tx, strings.TrimPrefix(actor, "web:"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var c claimRecord
|
||||
c.UserID = w.UserID
|
||||
c.TenantID = d.TenantID
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,household_id::text FROM subject_profile WHERE public_id=$1::uuid AND tenant_id=$2::uuid`, in.ElderID, d.TenantID).Scan(&c.SubjectID, &c.HouseholdID)
|
||||
if err != nil {
|
||||
return errAccess
|
||||
}
|
||||
if err = claimPermission(ctx, tx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
var valid bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding_claim WHERE setup_id=$1::uuid AND device_id=$2::uuid AND user_id=$3::uuid AND subject_id=$4::uuid AND status='PENDING' AND expires_at>now())`, w.SetupID, d.ID, w.UserID, c.SubjectID).Scan(&valid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return fail(410, "CLAIM_EXPIRED", "请查询当前绑定进度")
|
||||
}
|
||||
case "attempt":
|
||||
var in AttemptInput
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
j, err := readJob(ctx, tx, d.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = validConfig(s, d, j, in.ActivationID, in.ConfigVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
var status string
|
||||
var expiry time.Time
|
||||
var conversation *string
|
||||
err = tx.QueryRow(ctx, `SELECT status,permit_expires_at,conversation_id::text FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid`, in.AttemptID, j.ID).Scan(&status, &expiry, &conversation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != "CONTACT_POSSIBLE" || conversation != nil || !expiry.After(s.now()) {
|
||||
return fail(409, "ATTEMPT_ALREADY_ISSUED", "请恢复原尝试的结果")
|
||||
}
|
||||
case "activation-ack":
|
||||
var in ActivationAckInput
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
j, err := readJob(ctx, tx, d.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validConfig(s, d, j, in.ActivationID, in.ConfigVersion)
|
||||
case "activation-result":
|
||||
var in ResultInput
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
j, err := readJob(ctx, tx, d.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var version int64
|
||||
err = tx.QueryRow(ctx, `SELECT config_version FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid`, in.AttemptID, j.ID).Scan(&version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if j.ID != in.ActivationID || j.Generation != d.Generation || j.ConfigVersion != version || j.Status == "BLOCKED" {
|
||||
return errState
|
||||
}
|
||||
return nil
|
||||
case "profile":
|
||||
var in ProfileInput
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
if in.HouseholdID != "" {
|
||||
w, err := s.webSession(ctx, tx, strings.TrimPrefix(actor, "web:"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var allowed bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM household h JOIN household_member hm ON hm.household_id=h.id WHERE h.public_id=$1::uuid AND h.status='active' AND hm.user_id=$2::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor'))`, in.HouseholdID, w.UserID).Scan(&allowed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed {
|
||||
return errAccess
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Validate the exact cached resource, never a replacement with similar inputs.
|
||||
func (s *Service) replayResultGuard(ctx context.Context, tx pgx.Tx, d Device, actor, scope string, result map[string]any) error {
|
||||
var valid bool
|
||||
var err error
|
||||
switch scope {
|
||||
case "handoff":
|
||||
token, _ := result["handoff_token"].(string)
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_handoff WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now())`, digest([]byte(token))).Scan(&valid)
|
||||
case "claim":
|
||||
id, _ := result["claim_id"].(string)
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding_claim WHERE id=$1::uuid AND device_id=$2::uuid AND status='PENDING' AND expires_at>now())`, id, d.ID).Scan(&valid)
|
||||
case "profile":
|
||||
id, _ := result["elder_id"].(string)
|
||||
w, e := s.webSession(ctx, tx, strings.TrimPrefix(actor, "web:"))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM subject_profile s JOIN household h ON h.id=s.household_id JOIN household_member m ON m.household_id=h.id WHERE s.public_id=$1::uuid AND s.tenant_id=$2::uuid AND h.status='active' AND m.user_id=$3::uuid AND m.status='active' AND m.role IN ('owner','admin','editor'))`, id, d.TenantID, w.UserID).Scan(&valid)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return fail(410, "REQUEST_RESOURCE_EXPIRED", "原请求对应资源已失效,请恢复当前流程")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var runtimePath = regexp.MustCompile(`^/device/v(?:2/conversations|3/agent-sessions)/([0-9a-f-]{36})(?:/(events|heartbeat|stop|activate|turns:batch|upload-batches))?$`)
|
||||
|
||||
func (s *Service) runtimeAdmission(r *http.Request, d Device, raw []byte) (bool, error) {
|
||||
create := r.URL.Path == "/device/v3/agent-sessions"
|
||||
match := runtimePath.FindStringSubmatch(r.URL.Path)
|
||||
if r.URL.Path == "/device/v2/conversations" {
|
||||
return false, deviceauth.ErrDeviceInactive
|
||||
}
|
||||
if len(match) > 0 {
|
||||
var current bool
|
||||
err := s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM conversation WHERE public_id=$1::uuid AND device_id=$2::uuid AND onboarding_generation=$3)`, match[1], d.ID, d.Generation).Scan(¤t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !current {
|
||||
return false, deviceauth.ErrDeviceInactive
|
||||
}
|
||||
}
|
||||
if !create && len(match) == 0 {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) >= 5 && (parts[3] == "upload-batches" || parts[3] == "media-segments") {
|
||||
if !uuidValid(parts[4]) {
|
||||
return false, deviceauth.ErrCredentialInvalid
|
||||
}
|
||||
table := "upload_batch"
|
||||
if parts[3] == "media-segments" {
|
||||
table = "media_segment"
|
||||
}
|
||||
var current bool
|
||||
err := s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM `+table+` m JOIN conversation c ON c.id=m.conversation_id WHERE m.public_id=$1::uuid AND c.device_id=$2::uuid AND c.onboarding_generation=$3)`, parts[4], d.ID, d.Generation).Scan(¤t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !current {
|
||||
return false, deviceauth.ErrDeviceInactive
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
var intent, activation, attempt, token string
|
||||
if create {
|
||||
var input struct {
|
||||
Intent string `json:"intent"`
|
||||
Activation string `json:"activation_id"`
|
||||
Attempt string `json:"attempt_id"`
|
||||
Token string `json:"permit_token"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
return false, deviceauth.ErrCredentialInvalid
|
||||
}
|
||||
intent = input.Intent
|
||||
activation = input.Activation
|
||||
attempt = input.Attempt
|
||||
token = input.Token
|
||||
}
|
||||
var ready bool
|
||||
err := s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM device_activation_job j JOIN device_binding b ON b.device_id=j.device_id AND b.generation=j.binding_generation AND b.unbound_at IS NULL WHERE j.device_id=$1::uuid AND j.binding_generation=$2 AND j.status='READY' AND j.config_ack_at IS NOT NULL AND j.config_expires_at>now())`, d.ID, d.Generation).Scan(&ready)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if ready && intent != "activation_test" {
|
||||
return false, nil
|
||||
}
|
||||
var valid bool
|
||||
if create {
|
||||
if intent != "activation_test" || !uuidV4(activation) || !uuidV4(attempt) || (token != "" && !validB64(token, 32)) {
|
||||
return false, deviceauth.ErrDeviceInactive
|
||||
}
|
||||
err = s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM device_activation_job j JOIN device_activation_attempt a ON a.activation_id=j.id JOIN device_binding b ON b.device_id=j.device_id AND b.generation=j.binding_generation AND b.unbound_at IS NULL WHERE j.device_id=$1::uuid AND j.id=$2::uuid AND a.id=$3::uuid AND j.binding_generation=$4 AND a.binding_generation=$4 AND a.config_version=j.config_version AND j.status IN ('REGISTERED_OBSERVED','CONTACT_POSSIBLE','READY') AND j.config_ack_at IS NOT NULL AND j.engine_ready_at IS NOT NULL AND j.config_expires_at>now() AND a.stage='CONVERSATION_TEST' AND a.executor='DEVICE' AND a.status='CONTACT_POSSIBLE' AND (a.permit_hash IS NULL OR a.permit_hash=$5) AND (a.permit_expires_at>now() OR a.conversation_id IS NOT NULL))`, d.ID, activation, attempt, d.Generation, digest([]byte(token))).Scan(&valid)
|
||||
} else {
|
||||
if len(match) > 2 && !oneOf(match[2], "", "events", "heartbeat", "stop") {
|
||||
return false, deviceauth.ErrDeviceInactive
|
||||
}
|
||||
err = s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM conversation c JOIN device_activation_attempt a ON a.conversation_id=c.id JOIN device_activation_job j ON j.id=a.activation_id WHERE c.public_id=$1::uuid AND c.device_id=$2::uuid AND j.binding_generation=$3 AND a.binding_generation=$3 AND a.config_version=j.config_version AND a.stage='CONVERSATION_TEST' AND a.status IN ('CONTACT_POSSIBLE','SUCCESS_OBSERVED','UNKNOWN'))`, match[1], d.ID, d.Generation).Scan(&valid)
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !valid {
|
||||
return false, deviceauth.ErrDeviceInactive
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/hardwareprovision"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
Status int
|
||||
Code, Message, Recovery string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return e.Code }
|
||||
func fail(status int, code, message string) *Error {
|
||||
return &Error{Status: status, Code: code, Message: message, Recovery: "NONE"}
|
||||
}
|
||||
|
||||
var errAuth = fail(401, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
var errAccess = fail(403, "RESOURCE_ACCESS_DENIED", "当前账号或设备无权执行此操作")
|
||||
var errState = fail(409, "BINDING_GENERATION_CONFLICT", "设备状态已变化,请重新同步")
|
||||
|
||||
type LegacyVerifier interface {
|
||||
Verify(*http.Request) (deviceauth.Identity, error)
|
||||
}
|
||||
type Options struct {
|
||||
PauseClaims bool
|
||||
TrustedProxyCIDRs []string
|
||||
Enabled bool
|
||||
MasterKey, EntryURL, ProviderMasterKey string
|
||||
AllowLocalHTTP bool
|
||||
TokenVerifier identity.TokenVerifier
|
||||
LegacyVerifier LegacyVerifier
|
||||
Registrar hardwareprovision.Registrar
|
||||
ActivationEnabled bool
|
||||
InstanceID, ProductKey, ProductSecret, BotID, Transport, MinimumSDKVersion string
|
||||
SMS SMSProvider
|
||||
SMSLoginEnabled bool
|
||||
SelfSignupTenantIDs []string
|
||||
Logger *slog.Logger
|
||||
}
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
redis *redis.Client
|
||||
crypt cryptor
|
||||
providerCrypt cryptor
|
||||
options Options
|
||||
now func() time.Time
|
||||
audit *audit.Repository
|
||||
outbox *outbox.Store
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, redisClient *redis.Client, options Options) (*Service, error) {
|
||||
if pool == nil || redisClient == nil {
|
||||
return nil, errors.New("onboarding requires PostgreSQL and Redis")
|
||||
}
|
||||
crypt, err := newCryptor(options.MasterKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, err := url.Parse(options.EntryURL)
|
||||
if err != nil || entry.Host == "" || entry.User != nil || entry.RawQuery != "" || entry.Fragment != "" || (entry.Scheme != "https" && !(options.AllowLocalHTTP && entry.Scheme == "http" && (entry.Hostname() == "localhost" || entry.Hostname() == "127.0.0.1"))) {
|
||||
return nil, errors.New("onboarding entry must be a fixed HTTPS URL")
|
||||
}
|
||||
if options.Logger == nil {
|
||||
options.Logger = slog.Default()
|
||||
}
|
||||
if options.ActivationEnabled && (len(options.ProductSecret) < 16 || options.InstanceID == "" || options.ProductKey == "" || options.BotID == "") {
|
||||
return nil, errors.New("onboarding activation requires configured provider")
|
||||
}
|
||||
if options.SMSLoginEnabled && options.SMS == nil {
|
||||
return nil, errors.New("SMS login requires a configured sender")
|
||||
}
|
||||
var providerCrypt cryptor
|
||||
if options.ProviderMasterKey != "" {
|
||||
providerCrypt, err = newCryptor(options.ProviderMasterKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if options.ActivationEnabled && providerCrypt.aead == nil {
|
||||
return nil, errors.New("onboarding activation requires provider credential master key")
|
||||
}
|
||||
return &Service{pool: pool, redis: redisClient, crypt: crypt, providerCrypt: providerCrypt, options: options, now: time.Now, audit: audit.NewRepository(), outbox: outbox.NewStore(pool)}, nil
|
||||
}
|
||||
func (s *Service) Enabled() bool { return s != nil && s.options.Enabled }
|
||||
|
||||
type Device struct {
|
||||
ID, PublicID, TenantID, FactoryID, Model, HardwareRevision, Status, Lifecycle, Mode string
|
||||
Generation, Revision int64
|
||||
BootID string
|
||||
Released bool
|
||||
KeyVersion int64
|
||||
Key []byte
|
||||
}
|
||||
type queryer interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
func readDevice(ctx context.Context, q queryer, id string, lock bool) (Device, error) {
|
||||
var d Device
|
||||
suffix := ""
|
||||
if lock {
|
||||
suffix = " FOR UPDATE OF d"
|
||||
}
|
||||
err := q.QueryRow(ctx, `SELECT d.id::text,d.public_id::text,d.tenant_id::text,COALESCE(d.factory_device_id,''),d.model,COALESCE(d.hardware_revision,''),d.status,d.activation_state,d.enrollment_mode,d.binding_generation,d.onboarding_revision,COALESCE(d.onboarding_boot_id::text,''),d.onboarding_released_at IS NOT NULL FROM device d JOIN tenant t ON t.id=d.tenant_id AND t.status='active' WHERE d.id=$1::uuid`+suffix, id).Scan(&d.ID, &d.PublicID, &d.TenantID, &d.FactoryID, &d.Model, &d.HardwareRevision, &d.Status, &d.Lifecycle, &d.Mode, &d.Generation, &d.Revision, &d.BootID, &d.Released)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return d, errAccess
|
||||
}
|
||||
return d, err
|
||||
}
|
||||
func deviceAllowed(d Device) bool {
|
||||
return d.Mode == "physical_confirm" && d.Status == "registered" && d.Released && d.Lifecycle != "REVOKED" && d.Lifecycle != "LOST"
|
||||
}
|
||||
|
||||
type State struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
PlatformDeviceID string `json:"-"`
|
||||
Revision int64 `json:"device_revision"`
|
||||
Generation int64 `json:"binding_generation"`
|
||||
BindingState string `json:"binding_state"`
|
||||
ActivationState string `json:"activation_state"`
|
||||
NextAction string `json:"-"`
|
||||
Hash string `json:"state_hash"`
|
||||
}
|
||||
|
||||
func stateHash(st State) string {
|
||||
return digest([]byte(fmt.Sprintf("STATE1\n%s\n%d\n%d\n%s\n%s", st.DeviceID, st.Revision, st.Generation, st.BindingState, st.ActivationState)))
|
||||
}
|
||||
func (s *Service) state(ctx context.Context, tx pgx.Tx, d Device) (State, error) {
|
||||
if err := s.expireClaims(ctx, tx, d.ID); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
d, err := readDevice(ctx, tx, d.ID, true)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
st := State{DeviceID: d.FactoryID, PlatformDeviceID: d.PublicID, Revision: d.Revision, Generation: d.Generation, BindingState: "UNBOUND", ActivationState: "NOT_STARTED", NextAction: "WAIT_BIND"}
|
||||
var bound, pending bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL),EXISTS(SELECT 1 FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING' AND expires_at>now())`, d.ID).Scan(&bound, &pending)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
err = tx.QueryRow(ctx, `SELECT status FROM device_activation_job WHERE device_id=$1::uuid`, d.ID).Scan(&st.ActivationState)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return st, err
|
||||
}
|
||||
if pending {
|
||||
st.BindingState = "CLAIM_PENDING"
|
||||
}
|
||||
if bound {
|
||||
st.BindingState = "BOUND"
|
||||
st.NextAction = "RESUME_ACTIVATION"
|
||||
}
|
||||
if st.ActivationState == "READY" && bound {
|
||||
st.NextAction = "ENTER_READY"
|
||||
}
|
||||
if !deviceAllowed(d) {
|
||||
st.NextAction = "CONTACT_SUPPORT"
|
||||
}
|
||||
st.Hash = stateHash(st)
|
||||
raw, _ := json.Marshal(st)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_state_snapshot(device_id,revision,binding_generation,state_hash,state) VALUES($1::uuid,$2,$3,$4,$5::jsonb) ON CONFLICT(device_id,revision) DO NOTHING`, d.ID, st.Revision, st.Generation, st.Hash, raw)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
var stored string
|
||||
err = tx.QueryRow(ctx, `SELECT state_hash FROM device_state_snapshot WHERE device_id=$1::uuid AND revision=$2`, d.ID, st.Revision).Scan(&stored)
|
||||
if err == nil && stored != st.Hash {
|
||||
return st, errors.New("onboarding state changed without revision")
|
||||
}
|
||||
return st, err
|
||||
}
|
||||
func bump(ctx context.Context, tx pgx.Tx, deviceID string) error {
|
||||
_, err := tx.Exec(ctx, `UPDATE device SET onboarding_revision=onboarding_revision+1 WHERE id=$1::uuid`, deviceID)
|
||||
return err
|
||||
}
|
||||
func (s *Service) auditEvent(ctx context.Context, tx pgx.Tx, d Device, actorType, actorID, action, requestID string, detail map[string]any) error {
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &d.TenantID, ActorType: actorType, ActorPublicID: &actorID, Action: action, ResourceType: "device", ResourceID: &d.PublicID, RequestID: requestID, Result: "success", Detail: detail})
|
||||
}
|
||||
|
||||
// The device lock protects both replay validation and the mutation. Cached
|
||||
// responses are encrypted and may never outlive their business authorization.
|
||||
func (s *Service) mutate(ctx context.Context, d Device, actor, scope, requestID string, raw []byte, ttl time.Duration, op func(pgx.Tx, Device) (map[string]any, error)) (map[string]any, error) {
|
||||
if !uuidV4(requestID) {
|
||||
return nil, fail(400, "REQUEST_ID_REQUIRED", "X-Request-ID必须是UUID v4")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
current, err := readDevice(ctx, tx, d.ID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !deviceAllowed(current) {
|
||||
return nil, errAccess
|
||||
}
|
||||
if current.Generation != d.Generation {
|
||||
return nil, errState
|
||||
}
|
||||
if strings.HasPrefix(actor, "device:") && d.KeyVersion > 0 {
|
||||
var valid bool
|
||||
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_onboarding_key WHERE device_id=$1::uuid AND key_version=$2 AND status='ACTIVE')`, d.ID, d.KeyVersion).Scan(&valid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !valid {
|
||||
return nil, errAuth
|
||||
}
|
||||
if scope != "register" && current.BootID != d.BootID {
|
||||
return nil, errState
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(actor, "web:") {
|
||||
if err = s.checkWebActor(ctx, tx, strings.TrimPrefix(actor, "web:"), current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
requestHash := digest(raw)
|
||||
aad := actor + "\n" + scope + "\n" + requestID
|
||||
var stored string
|
||||
var generation int64
|
||||
var encrypted []byte
|
||||
var expires time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT request_digest,binding_generation,response_ciphertext,expires_at FROM device_onboarding_request WHERE actor_key=$1 AND scope=$2 AND request_id=$3::uuid`, actor, scope, requestID).Scan(&stored, &generation, &encrypted, &expires)
|
||||
if err == nil {
|
||||
if stored != requestHash {
|
||||
return nil, fail(409, "REQUEST_BODY_CONFLICT", "相同请求号不能提交不同内容")
|
||||
}
|
||||
if generation != current.Generation {
|
||||
return nil, errState
|
||||
}
|
||||
if !expires.After(s.now()) {
|
||||
return nil, fail(410, "REQUEST_RESULT_EXPIRED", "该请求的短时结果已过期,请恢复当前流程")
|
||||
}
|
||||
if err = s.replayGuard(ctx, tx, current, actor, scope, raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := s.crypt.open(encrypted, aad)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result map[string]any
|
||||
if err = json.Unmarshal(plain, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.replayResultGuard(ctx, tx, current, actor, scope, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ACK persistence is idempotent, but close readiness changes as the
|
||||
// grace period elapses. Recompute it without moving accepted_at.
|
||||
if scope == "state-ack" {
|
||||
result["setup_can_close"], err = s.stateAckCanClose(ctx, tx, current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if scope == "activation-result" || scope == "activation-ack" {
|
||||
st, e := s.state(ctx, tx, current)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
result["state"] = st
|
||||
if scope == "activation-result" {
|
||||
result["reconcile_required"] = st.ActivationState == "RECONCILE_REQUIRED"
|
||||
}
|
||||
}
|
||||
return result, tx.Commit(ctx)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
result, err := op(tx, current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err = readDevice(ctx, tx, d.ID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_onboarding_request(actor_key,scope,request_id,request_digest,device_id,binding_generation,response_ciphertext,expires_at) VALUES($1,$2,$3::uuid,$4,$5::uuid,$6,$7,$8)`, actor, scope, requestID, requestHash, d.ID, current.Generation, s.crypt.seal(payload, aad), s.now().Add(ttl))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type RegisterInput struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
BootID string `json:"boot_id"`
|
||||
Model string `json:"hardware_model"`
|
||||
HardwareRevision string `json:"hardware_revision"`
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
SDKVersion string `json:"sdk_version"`
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, d Device, requestID string, raw []byte, in RegisterInput) (map[string]any, error) {
|
||||
if !uuidV4(in.ChallengeID) || !uuidV4(in.BootID) || in.Model != d.Model || in.HardwareRevision != d.HardwareRevision || len(in.FirmwareVersion) < 1 || len(in.FirmwareVersion) > 32 || len(in.SDKVersion) < 1 || len(in.SDKVersion) > 64 {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "注册字段与生产记录不一致")
|
||||
}
|
||||
return s.mutate(ctx, d, "device:"+d.ID, "register", requestID, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
|
||||
tag, err := tx.Exec(ctx, `UPDATE device_time_challenge SET consumed_by=$1::uuid WHERE id=$2::uuid AND device_id=$3::uuid AND key_version=$4 AND boot_id=$5::uuid AND expires_at>now() AND consumed_by IS NULL`, requestID, in.ChallengeID, d.ID, d.KeyVersion, in.BootID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return nil, fail(409, "CHALLENGE_INVALID", "请重新获取校时挑战")
|
||||
}
|
||||
if current.BootID != "" && current.BootID != in.BootID {
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='DEVICE_REBOOT' WHERE device_id=$1::uuid`, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A new boot cannot reuse a still-live pre-reboot execution permit:
|
||||
// the previous SDK call may have reached the supplier before power loss.
|
||||
pending, e := tx.Exec(ctx, `UPDATE device_activation_attempt a SET status='UNKNOWN' FROM device_activation_job j WHERE a.activation_id=j.id AND j.device_id=$1::uuid AND a.executor='DEVICE' AND a.status='CONTACT_POSSIBLE'`, d.ID)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if pending.RowsAffected() > 0 {
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='RECONCILE_REQUIRED',last_error_code='DEVICE_REBOOT_DURING_ATTEMPT',version=version+1,updated_at=now() WHERE device_id=$1::uuid`, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device SET onboarding_boot_id=$2::uuid,onboarding_last_seen_at=now(),onboarding_revision=onboarding_revision+1 WHERE id=$1::uuid`, d.ID, in.BootID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_twin SET firmware_version=$2,board_revision=$3,rtc_sdk_version=$4,last_reported_at=now(),updated_at=now() WHERE device_id=$1::uuid`, d.ID, in.FirmwareVersion, in.HardwareRevision, in.SDKVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := s.state(ctx, tx, d)
|
||||
return map[string]any{"state": st, "contract_version": ContractVersion}, err
|
||||
})
|
||||
}
|
||||
|
||||
type SetupInput struct {
|
||||
BootID string `json:"boot_id"`
|
||||
LocalSetupID string `json:"local_setup_id"`
|
||||
Transport string `json:"transport_profile"`
|
||||
}
|
||||
type setup struct {
|
||||
ID, DeviceID, BootID, LocalID, Mode string
|
||||
Generation int64
|
||||
Expires time.Time
|
||||
Closed *time.Time
|
||||
}
|
||||
|
||||
func readSetup(ctx context.Context, tx pgx.Tx, id string, d Device, open bool) (setup, error) {
|
||||
var v setup
|
||||
if !uuidV4(id) {
|
||||
return v, fail(400, "VALIDATION_FAILED", "设置会话编号不正确")
|
||||
}
|
||||
err := tx.QueryRow(ctx, `SELECT id::text,device_id::text,boot_id::text,local_setup_id::text,mode,binding_generation,expires_at,closed_at FROM device_setup_session WHERE id=$1::uuid AND device_id=$2::uuid AND tenant_id=$3::uuid FOR UPDATE`, id, d.ID, d.TenantID).Scan(&v.ID, &v.DeviceID, &v.BootID, &v.LocalID, &v.Mode, &v.Generation, &v.Expires, &v.Closed)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return v, errAccess
|
||||
}
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.BootID != d.BootID {
|
||||
return v, fail(409, "BOOT_CHANGED", "设备已重启,请恢复新的设置会话")
|
||||
}
|
||||
if v.Generation != d.Generation {
|
||||
return v, errState
|
||||
}
|
||||
if open && (v.Closed != nil || !v.Expires.After(time.Now())) {
|
||||
return v, fail(410, "SETUP_EXPIRED", "设置入口已关闭,请从设备恢复")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (s *Service) Setup(ctx context.Context, d Device, requestID string, raw []byte, in SetupInput) (map[string]any, error) {
|
||||
if !uuidV4(in.BootID) || !uuidV4(in.LocalSetupID) || in.Transport != "LOCAL_WEB" {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "设置会话参数不正确")
|
||||
}
|
||||
return s.mutate(ctx, d, "device:"+d.ID, "setup", requestID, raw, 15*time.Minute, func(tx pgx.Tx, current Device) (map[string]any, error) {
|
||||
if current.BootID != in.BootID {
|
||||
return nil, fail(409, "BOOT_CHANGED", "请先完成本次启动注册")
|
||||
}
|
||||
st, err := s.state(ctx, tx, current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mode := "ENROLL"
|
||||
if st.BindingState == "BOUND" {
|
||||
mode = "RECOVERY"
|
||||
}
|
||||
if mode == "ENROLL" && !s.Enabled() {
|
||||
return nil, fail(503, "ENROLLMENT_PAUSED", "首次绑定入口暂停")
|
||||
}
|
||||
var id string
|
||||
var expires time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,expires_at FROM device_setup_session WHERE device_id=$1::uuid AND boot_id=$2::uuid AND local_setup_id=$3::uuid AND binding_generation=$4 AND mode=$5 AND closed_at IS NULL AND expires_at>now() ORDER BY created_at DESC LIMIT 1`, d.ID, in.BootID, in.LocalSetupID, current.Generation, mode).Scan(&id, &expires)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
expires = s.now().Add(15 * time.Minute)
|
||||
err = tx.QueryRow(ctx, `INSERT INTO device_setup_session(device_id,tenant_id,boot_id,local_setup_id,binding_generation,mode,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5,$6,$7) RETURNING id::text`, d.ID, d.TenantID, in.BootID, in.LocalSetupID, current.Generation, mode, expires).Scan(&id)
|
||||
}
|
||||
return map[string]any{"setup_id": id, "expires_at_ms": expires.UnixMilli(), "poll_after_ms": 2000, "state": st}, err
|
||||
})
|
||||
}
|
||||
|
||||
type HandoffInput struct {
|
||||
SetupID string `json:"setup_id"`
|
||||
LocalID string `json:"local_setup_id"`
|
||||
Challenge string `json:"code_challenge"`
|
||||
}
|
||||
|
||||
func (s *Service) Handoff(ctx context.Context, d Device, requestID string, raw []byte, in HandoffInput) (map[string]any, error) {
|
||||
if !uuidV4(in.LocalID) || !validB64(in.Challenge, 32) {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "入口挑战不正确")
|
||||
}
|
||||
return s.mutate(ctx, d, "device:"+d.ID, "handoff", requestID, raw, 90*time.Second, func(tx pgx.Tx, current Device) (map[string]any, error) {
|
||||
setup, err := readSetup(ctx, tx, in.SetupID, current, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if setup.Mode != "ENROLL" || setup.LocalID != in.LocalID {
|
||||
return nil, errAccess
|
||||
}
|
||||
var bound bool
|
||||
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, d.ID).Scan(&bound); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bound {
|
||||
return nil, errAccess
|
||||
}
|
||||
token := randomToken(32)
|
||||
expires := s.now().Add(90 * time.Second)
|
||||
if setup.Expires.Before(expires) {
|
||||
expires = setup.Expires
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_handoff(token_hash,setup_id,code_challenge,expires_at) VALUES($1,$2::uuid,$3,$4)`, digest([]byte(token)), setup.ID, in.Challenge, expires)
|
||||
return map[string]any{"handoff_token": token, "expires_at_ms": expires.UnixMilli(), "entry_url": s.options.EntryURL}, err
|
||||
})
|
||||
}
|
||||
|
||||
type PollInput struct {
|
||||
SetupID string `json:"setup_id"`
|
||||
BootID string `json:"boot_id"`
|
||||
KnownRevision int64 `json:"known_revision"`
|
||||
}
|
||||
|
||||
func (s *Service) Poll(ctx context.Context, d Device, in PollInput) (map[string]any, error) {
|
||||
if !uuidV4(in.BootID) || in.KnownRevision < 0 {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "轮询参数不正确")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
d, err = readDevice(ctx, tx, d.ID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !deviceAllowed(d) {
|
||||
return nil, errAccess
|
||||
}
|
||||
setup, err := readSetup(ctx, tx, in.SetupID, d, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.BootID != d.BootID {
|
||||
return nil, errState
|
||||
}
|
||||
if err = s.expireClaims(ctx, tx, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := s.state(ctx, tx, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var command any
|
||||
if setup.Closed == nil && setup.Expires.After(s.now()) && setup.Mode == "ENROLL" {
|
||||
var id, hash, masked string
|
||||
var encrypted []byte
|
||||
var expires time.Time
|
||||
var gen int64
|
||||
err = tx.QueryRow(ctx, `SELECT c.id::text,c.challenge_ciphertext,c.binding_digest,c.expected_generation,c.expires_at,COALESCE(ua.display_name,'') FROM device_binding_claim c JOIN user_account ua ON ua.id=c.user_id WHERE c.device_id=$1::uuid AND c.setup_id=$2::uuid AND c.status='PENDING' AND c.expires_at>now()`, d.ID, setup.ID).Scan(&id, &encrypted, &hash, &gen, &expires, &masked)
|
||||
if err == nil {
|
||||
plain, e := s.crypt.open(encrypted, "claim:"+id)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
runes := []rune(masked)
|
||||
if len(runes) > 4 {
|
||||
masked = "尾号" + string(runes[len(runes)-4:])
|
||||
} else {
|
||||
masked = "当前登录账号"
|
||||
}
|
||||
command = map[string]any{"command_id": id, "type": "CONFIRM_CLAIM", "expires_at_ms": expires.UnixMilli(), "payload": map[string]any{"claim_id": id, "challenge": string(plain), "binding_digest": hash, "expected_binding_generation": gen, "masked_account": masked}}
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if command == nil && st.BindingState == "BOUND" {
|
||||
command, err = s.activationCommand(ctx, tx, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
st, err = s.state(ctx, tx, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
interval := 2000
|
||||
if st.BindingState == "BOUND" {
|
||||
interval = 10000
|
||||
}
|
||||
if st.ActivationState == "READY" {
|
||||
interval = 60000
|
||||
}
|
||||
return map[string]any{"state": st, "command": command, "poll_after_ms": interval}, tx.Commit(ctx)
|
||||
}
|
||||
func (s *Service) expireClaims(ctx context.Context, tx pgx.Tx, id string) error {
|
||||
tag, err := tx.Exec(ctx, `UPDATE device_binding_claim SET status='EXPIRED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING' AND expires_at<=now()`, id)
|
||||
if err == nil && tag.RowsAffected() > 0 {
|
||||
err = bump(ctx, tx, id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type ConfirmationInput struct {
|
||||
ClaimID string `json:"claim_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
Digest string `json:"binding_digest"`
|
||||
ExpectedGeneration int64 `json:"expected_binding_generation"`
|
||||
PressEventID string `json:"press_event_id"`
|
||||
}
|
||||
|
||||
func (s *Service) Confirm(ctx context.Context, d Device, requestID string, raw []byte, in ConfirmationInput) (map[string]any, error) {
|
||||
if !uuidV4(in.ClaimID) || !uuidV4(in.PressEventID) || !validB64(in.Challenge, 32) || !validDigest(in.Digest) || in.ExpectedGeneration < 0 {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "设备确认字段不正确")
|
||||
}
|
||||
return s.mutate(ctx, d, "device:"+d.ID, "confirm", requestID, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
|
||||
var claim claimRecord
|
||||
var err error
|
||||
claim, err = readClaim(ctx, tx, in.ClaimID, current.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = claimPermission(ctx, tx, claim); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hmac.Equal([]byte(claim.ChallengeHash), []byte(digest([]byte(in.Challenge)))) || !hmac.Equal([]byte(claim.Digest), []byte(in.Digest)) || claim.Generation != in.ExpectedGeneration {
|
||||
return nil, errState
|
||||
}
|
||||
if claim.Status == "CONFIRMED" {
|
||||
if claim.PressID != in.PressEventID || current.Generation != claim.Generation+1 {
|
||||
return nil, errState
|
||||
}
|
||||
var active bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE id=$1::uuid AND unbound_at IS NULL AND generation=$2)`, claim.BindingID, current.Generation).Scan(&active)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !active {
|
||||
return nil, errState
|
||||
}
|
||||
st, e := s.state(ctx, tx, current)
|
||||
return map[string]any{"claim_id": claim.ID, "state": st, "already_applied": true}, e
|
||||
}
|
||||
if current.Generation != in.ExpectedGeneration {
|
||||
return nil, errState
|
||||
}
|
||||
setup, err := readSetup(ctx, tx, claim.SetupID, current, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if setup.Mode != "ENROLL" {
|
||||
return nil, errAccess
|
||||
}
|
||||
if claim.Status != "PENDING" || !claim.Expires.After(s.now()) {
|
||||
return nil, fail(410, "CLAIM_EXPIRED", "绑定申请已过期或取消")
|
||||
}
|
||||
var active bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, d.ID).Scan(&active)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active {
|
||||
return nil, errState
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `SELECT set_config('app.onboarding_claim',$1,true)`, claim.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var bindingID string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,'physical_confirm') RETURNING id::text`, d.TenantID, d.ID, claim.HouseholdID, claim.SubjectID, claim.UserID).Scan(&bindingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_binding_claim SET status='CONFIRMED',press_event_id=$2::uuid,binding_id=$3::uuid,completed_at=now() WHERE id=$1::uuid`, claim.ID, in.PressEventID, bindingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_setup_session SET binding_generation=$2 WHERE id=$1::uuid`, setup.ID, current.Generation+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: d.ID, TenantID: d.TenantID, Target: deviceactivation.Bound, Reason: "设备按键确认绑定", Source: "onboarding", ActorType: "device", ActorPublicID: &d.PublicID, RequestID: requestID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.enqueueActivation(ctx, tx, current, current.Generation+1, requestID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Match the platform's basic voice mode. Optional storage, extraction and
|
||||
// personalization stay off until their existing consent flows are used.
|
||||
_, err = tx.Exec(ctx, `INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) SELECT $1::uuid,$2::uuid,1,'{"voice_chat":true,"transcript_storage":false,"raw_audio_storage":false,"story_extraction":false,"personalized_memory":false,"family_sharing":false}'::jsonb,'[]'::jsonb WHERE NOT EXISTS(SELECT 1 FROM consent_snapshot WHERE tenant_id=$1::uuid AND subject_id=$2::uuid)`, current.TenantID, claim.SubjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.auditEvent(ctx, tx, d, "device", d.PublicID, "device.binding.confirm", requestID, map[string]any{"claim_id": claim.ID, "binding_generation": current.Generation + 1}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := s.state(ctx, tx, current)
|
||||
return map[string]any{"claim_id": claim.ID, "state": st, "already_applied": false}, err
|
||||
})
|
||||
}
|
||||
|
||||
type claimRecord struct {
|
||||
ID, SetupID, DeviceID, TenantID, UserID, HouseholdID, SubjectID, ChallengeHash, Digest, Status, BindingID, PressID string
|
||||
Generation int64
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
func readClaim(ctx context.Context, tx pgx.Tx, id, device string) (claimRecord, error) {
|
||||
var c claimRecord
|
||||
err := tx.QueryRow(ctx, `SELECT id::text,setup_id::text,device_id::text,tenant_id::text,user_id::text,household_id::text,subject_id::text,challenge_hash,binding_digest,status,COALESCE(binding_id::text,''),COALESCE(press_event_id::text,''),expected_generation,expires_at FROM device_binding_claim WHERE id=$1::uuid AND device_id=$2::uuid FOR UPDATE`, id, device).Scan(&c.ID, &c.SetupID, &c.DeviceID, &c.TenantID, &c.UserID, &c.HouseholdID, &c.SubjectID, &c.ChallengeHash, &c.Digest, &c.Status, &c.BindingID, &c.PressID, &c.Generation, &c.Expires)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c, errAccess
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
func claimPermission(ctx context.Context, tx pgx.Tx, c claimRecord) error {
|
||||
var allowed bool
|
||||
err := tx.QueryRow(ctx, `SELECT true FROM household_member hm JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN household h ON h.id=hm.household_id AND h.status='active' JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id WHERE ua.id=$1::uuid AND hm.household_id=$2::uuid AND sp.id=$3::uuid AND hm.tenant_id=$4::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') AND sp.status='active' FOR SHARE OF ua,hm,h,sp`, c.UserID, c.HouseholdID, c.SubjectID, c.TenantID).Scan(&allowed)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return errAccess
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed {
|
||||
return errAccess
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type StateAckInput struct {
|
||||
Revision int64 `json:"device_revision"`
|
||||
Generation int64 `json:"binding_generation"`
|
||||
LocalGeneration int64 `json:"local_generation"`
|
||||
Hash string `json:"state_hash"`
|
||||
}
|
||||
|
||||
func (s *Service) StateAck(ctx context.Context, d Device, requestID string, raw []byte, in StateAckInput) (map[string]any, error) {
|
||||
if in.Revision < 1 || in.Generation < 0 || in.LocalGeneration < 0 || !validDigest(in.Hash) {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "状态回执不正确")
|
||||
}
|
||||
return s.mutate(ctx, d, "device:"+d.ID, "state-ack", requestID, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
|
||||
st, err := s.state(ctx, tx, current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st.Revision != in.Revision || st.Generation != in.Generation || st.Hash != in.Hash {
|
||||
return nil, errState
|
||||
}
|
||||
var local int64
|
||||
var boot string
|
||||
err = tx.QueryRow(ctx, `SELECT local_generation,boot_id::text FROM device_state_ack WHERE device_id=$1::uuid`, d.ID).Scan(&local, &boot)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil && boot == current.BootID && local > in.LocalGeneration {
|
||||
return nil, errState
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_state_ack(device_id,revision,binding_generation,state_hash,boot_id,local_generation) VALUES($1::uuid,$2,$3,$4,$5::uuid,$6) ON CONFLICT(device_id) DO UPDATE SET revision=EXCLUDED.revision,binding_generation=EXCLUDED.binding_generation,state_hash=EXCLUDED.state_hash,boot_id=EXCLUDED.boot_id,local_generation=EXCLUDED.local_generation,accepted_at=CASE WHEN device_state_ack.binding_generation=EXCLUDED.binding_generation AND device_state_ack.boot_id=EXCLUDED.boot_id THEN device_state_ack.accepted_at ELSE now() END`, d.ID, in.Revision, in.Generation, in.Hash, current.BootID, in.LocalGeneration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canClose, err := s.stateAckCanClose(ctx, tx, current)
|
||||
return map[string]any{"accepted_revision": in.Revision, "setup_can_close": canClose}, err
|
||||
})
|
||||
}
|
||||
|
||||
type CloseInput struct {
|
||||
SetupID string `json:"setup_id"`
|
||||
Reason string `json:"reason"`
|
||||
Revision int64 `json:"final_revision"`
|
||||
}
|
||||
|
||||
func (s *Service) Close(ctx context.Context, d Device, requestID string, raw []byte, in CloseInput) (map[string]any, error) {
|
||||
if !uuidV4(in.SetupID) || in.Revision < 1 || !oneOf(in.Reason, "SUCCESS", "TIMEOUT", "CANCELLED", "NETWORK_SWITCH") {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "收尾参数不正确")
|
||||
}
|
||||
return s.mutate(ctx, d, "device:"+d.ID, "close", requestID, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
|
||||
setup, err := readSetup(ctx, tx, in.SetupID, current, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Revision > current.Revision {
|
||||
return nil, errState
|
||||
}
|
||||
if in.Reason == "SUCCESS" {
|
||||
_, allowed, e := s.closeDelay(ctx, tx, current, setup.ID)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if !allowed {
|
||||
return nil, fail(409, "SETUP_CLOSE_WAIT", "请等待设备和页面回执的收尾时间")
|
||||
}
|
||||
var saved bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_state_ack a JOIN device_binding b ON b.device_id=a.device_id AND b.unbound_at IS NULL WHERE a.device_id=$1::uuid AND a.binding_generation=$2 AND a.boot_id=$3::uuid)`, d.ID, current.Generation, current.BootID).Scan(&saved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !saved {
|
||||
return nil, fail(409, "STATE_ACK_REQUIRED", "设备保存后才能确认绑定收尾")
|
||||
}
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason=COALESCE(close_reason,$2) WHERE id=$1::uuid`, setup.ID, in.Reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE setup_id=$1::uuid AND status='PENDING'`, setup.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
if err = bump(ctx, tx, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
st, err := s.state(ctx, tx, current)
|
||||
return map[string]any{"close_allowed": true, "state": st}, err
|
||||
})
|
||||
}
|
||||
func oneOf(s string, values ...string) bool {
|
||||
for _, v := range values {
|
||||
if s == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func bindingDigest(d Device, user, household, subject, claim string) string {
|
||||
return digest([]byte(fmt.Sprintf("CLAIM1\n%s\n%s\n%s\n%s\n%s\n%d", d.FactoryID, user, household, subject, claim, d.Generation)))
|
||||
}
|
||||
func newClaim(ctx context.Context, s *Service, tx pgx.Tx, d Device, setupID, user, household, subject string) (map[string]any, error) {
|
||||
if err := s.expireClaims(ctx, tx, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pending bool
|
||||
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING')`, d.ID).Scan(&pending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pending {
|
||||
return nil, fail(409, "CLAIM_BUSY", "设备正在等待另一笔确认,请稍后再试")
|
||||
}
|
||||
id := uuid.NewString()
|
||||
challenge := randomToken(32)
|
||||
hash := bindingDigest(d, user, household, subject, id)
|
||||
expires := s.now().Add(120 * time.Second)
|
||||
setup, err := readSetup(ctx, tx, setupID, d, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if setup.Expires.Before(expires) {
|
||||
expires = setup.Expires
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_binding_claim(id,device_id,tenant_id,setup_id,user_id,household_id,subject_id,expected_generation,challenge_hash,challenge_ciphertext,binding_digest,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7::uuid,$8,$9,$10,$11,$12)`, id, d.ID, d.TenantID, setupID, user, household, subject, d.Generation, digest([]byte(challenge)), s.crypt.seal([]byte(challenge), "claim:"+id), hash, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = bump(ctx, tx, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := s.state(ctx, tx, d)
|
||||
return map[string]any{"claim_id": id, "expires_at_ms": expires.UnixMilli(), "state": st}, err
|
||||
}
|
||||
|
||||
// Used by protocol tests without leaking transient challenges into logs.
|
||||
func requestJSON(v any) []byte { b, _ := json.Marshal(v); return b }
|
||||
func nonempty(v string, max int) bool { return len(strings.TrimSpace(v)) > 0 && len(v) <= max }
|
||||
@@ -0,0 +1,240 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type SMSProvider interface {
|
||||
Send(context.Context, string, string, string) error
|
||||
}
|
||||
type HTTPSMSProvider struct {
|
||||
endpoint, token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPSMSProvider(endpoint, token string) (SMSProvider, error) {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || token == "" {
|
||||
return nil, errors.New("SMS gateway requires fixed HTTPS endpoint and access token")
|
||||
}
|
||||
return &HTTPSMSProvider{endpoint: endpoint, token: token, client: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
|
||||
}
|
||||
|
||||
// The gateway must deduplicate request_id. A timeout is never retried locally.
|
||||
func (p *HTTPSMSProvider) Send(ctx context.Context, phone, code, id string) error {
|
||||
raw, _ := json.Marshal(map[string]any{"phone_e164": phone, "code": code, "purpose": "LOGIN", "request_id": id, "expires_in_seconds": 300})
|
||||
r, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Header.Set("Authorization", "Bearer "+p.token)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("Idempotency-Key", id)
|
||||
response, err := p.client.Do(r)
|
||||
if err != nil {
|
||||
return errors.New("SMS_GATEWAY_UNCERTAIN")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return errors.New("SMS_GATEWAY_REJECTED")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var phonePattern = regexp.MustCompile(`^\+[1-9][0-9]{7,14}$`)
|
||||
var smsCodePattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||
|
||||
type SMSSendInput struct {
|
||||
Phone string `json:"phone_e164"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
|
||||
func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, in SMSSendInput) (map[string]any, error) {
|
||||
if !s.options.SMSLoginEnabled {
|
||||
return nil, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放,请使用账号登录")
|
||||
}
|
||||
if !uuidV4(requestID) || !phonePattern.MatchString(in.Phone) || in.Purpose != "LOGIN" {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "请使用包含国家区号的手机号码")
|
||||
}
|
||||
phoneHash := domainMAC(s.crypt.hashKey, "PHONE1\n"+in.Phone)
|
||||
// Keyed phone hashes prevent offline enumeration of stored phone numbers.
|
||||
if err := s.limit(ctx, "sms:global", 1000, time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.limit(ctx, "sms:session:"+w.Hash, 10, time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
d, err := readDevice(ctx, tx, w.Device.ID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "sms:"+phoneHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var id, status, stored string
|
||||
var expires time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,status,phone_digest,expires_at FROM onboarding_sms_challenge WHERE session_hash=$1 AND request_id=$2::uuid`, w.Hash, requestID).Scan(&id, &status, &stored, &expires)
|
||||
if err == nil {
|
||||
if stored != phoneHash {
|
||||
return nil, fail(409, "REQUEST_BODY_CONFLICT", "请求号已用于其他手机号码")
|
||||
}
|
||||
if status != "SENT" || !expires.After(s.now()) {
|
||||
return nil, fail(409, "SMS_SEND_UNCERTAIN", "请等待重发间隔后重新申请验证码")
|
||||
}
|
||||
return smsSendResult(id, expires), tx.Commit(ctx)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
var recent, hourCount int
|
||||
err = tx.QueryRow(ctx, `SELECT count(*) FILTER(WHERE created_at>now()-interval '60 seconds'),count(*) FROM onboarding_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'`, phoneHash).Scan(&recent, &hourCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if recent > 0 || hourCount >= 5 {
|
||||
return nil, fail(429, "SMS_RATE_LIMITED", "发送过于频繁,请稍后再试")
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code := fmt.Sprintf("%06d", n.Int64())
|
||||
id = uuid.NewString()
|
||||
expires = s.now().Add(5 * time.Minute)
|
||||
codeHash := domainMAC(s.smsHashKey(), id+"\n"+code)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO onboarding_sms_challenge(id,session_hash,phone_digest,phone_ciphertext,code_hash,request_id,expires_at) VALUES($1::uuid,$2,$3,$4,$5,$6::uuid,$7)`, id, w.Hash, phoneHash, s.crypt.seal([]byte(in.Phone), "sms-phone:"+id), codeHash, requestID, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sendErr := s.options.SMS.Send(ctx, in.Phone, code, id)
|
||||
status = "SENT"
|
||||
if sendErr != nil {
|
||||
status = "FAILED"
|
||||
}
|
||||
if _, err = s.pool.Exec(ctx, `UPDATE onboarding_sms_challenge SET status=$2 WHERE id=$1::uuid AND status='SENDING'`, id, status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sendErr != nil {
|
||||
return nil, fail(503, "SMS_SEND_UNCERTAIN", "发送结果未确认,请等待重发间隔后再试")
|
||||
}
|
||||
return smsSendResult(id, expires), nil
|
||||
}
|
||||
func (s *Service) smsHashKey() []byte { return []byte(domainMAC(s.crypt.hashKey, "SMS-CODE-KEY1")) }
|
||||
func smsSendResult(id string, expires time.Time) map[string]any {
|
||||
return map[string]any{"sms_challenge_id": id, "expires_at_ms": expires.UnixMilli(), "resend_after_ms": 60000}
|
||||
}
|
||||
|
||||
type SMSVerifyInput struct {
|
||||
ChallengeID string `json:"sms_challenge_id"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput) (BrowserCredentials, error) {
|
||||
var out BrowserCredentials
|
||||
if !s.options.SMSLoginEnabled {
|
||||
return out, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放")
|
||||
}
|
||||
if !uuidV4(in.ChallengeID) || !smsCodePattern.MatchString(in.Code) {
|
||||
return out, fail(400, "VALIDATION_FAILED", "验证码格式不正确")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
d, err := readDevice(ctx, tx, w.Device.ID, true)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
|
||||
return out, err
|
||||
}
|
||||
var expected, phoneHash, status string
|
||||
var encrypted []byte
|
||||
var attempts int
|
||||
var expires time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT code_hash,phone_digest,phone_ciphertext,status,attempts,expires_at FROM onboarding_sms_challenge WHERE id=$1::uuid AND session_hash=$2 FOR UPDATE`, in.ChallengeID, w.Hash).Scan(&expected, &phoneHash, &encrypted, &status, &attempts, &expires)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if status != "SENT" || attempts >= 5 || !expires.After(s.now()) {
|
||||
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
|
||||
}
|
||||
if !hmac.Equal([]byte(expected), []byte(domainMAC(s.smsHashKey(), in.ChallengeID+"\n"+in.Code))) {
|
||||
if _, err = tx.Exec(ctx, `UPDATE onboarding_sms_challenge SET attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
|
||||
}
|
||||
phone, err := s.crypt.open(encrypted, "sms-phone:"+in.ChallengeID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
masked := "用户" + string(phone[len(phone)-4:])
|
||||
p := identity.Principal{Issuer: "urn:platform:onboarding:sms", Subject: phoneHash, DisplayName: masked}
|
||||
out, err = s.attachPrincipal(ctx, tx, w, d, p, true)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE onboarding_sms_challenge SET status='CONSUMED',consumed_at=now(),attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Account cookies are accepted only by the explicit customer-route adapter.
|
||||
func (s *Service) AccountPrincipal(ctx context.Context, token, csrf string, write bool) (identity.Principal, error) {
|
||||
var p identity.Principal
|
||||
var expected string
|
||||
if !validB64(token, 32) {
|
||||
return p, errAccess
|
||||
}
|
||||
err := s.pool.QueryRow(ctx, `SELECT ua.oidc_issuer,ua.oidc_subject,COALESCE(ua.display_name,''),ss.csrf_hash FROM onboarding_account_session ss JOIN user_account ua ON ua.id=ss.user_id AND ua.status='active' JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ss.token_hash=$1 AND ss.expires_at>now() AND ss.revoked_at IS NULL`, digest([]byte(token))).Scan(&p.Issuer, &p.Subject, &p.DisplayName, &expected)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return p, errAccess
|
||||
}
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
if write && (!validB64(csrf, 32) || !hmac.Equal([]byte(expected), []byte(digest([]byte(csrf))))) {
|
||||
return p, errAccess
|
||||
}
|
||||
p.DisplayName = strings.TrimSpace(p.DisplayName)
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type WebSession struct {
|
||||
Hash, SetupID, UserID, Issuer, Subject string
|
||||
Device Device
|
||||
Expires time.Time
|
||||
}
|
||||
type BrowserCredentials struct {
|
||||
Token string `json:"-"`
|
||||
CSRF string `json:"csrf_token"`
|
||||
AccountToken string `json:"-"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
func (s *Service) webSession(ctx context.Context, q queryer, hash string) (WebSession, error) {
|
||||
var w WebSession
|
||||
var id string
|
||||
err := q.QueryRow(ctx, `SELECT ws.token_hash,ws.setup_id::text,COALESCE(ws.user_id::text,''),COALESCE(ws.issuer,''),COALESCE(ws.subject,''),ws.expires_at,ss.device_id::text FROM onboarding_web_session ws JOIN device_setup_session ss ON ss.id=ws.setup_id WHERE ws.token_hash=$1 AND ws.revoked_at IS NULL AND ws.expires_at>now()`, hash).Scan(&w.Hash, &w.SetupID, &w.UserID, &w.Issuer, &w.Subject, &w.Expires, &id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return w, fail(401, "WEB_SESSION_EXPIRED", "请从设备重新打开设置入口")
|
||||
}
|
||||
if err != nil {
|
||||
return w, err
|
||||
}
|
||||
w.Device, err = readDevice(ctx, q, id, false)
|
||||
return w, err
|
||||
}
|
||||
func (s *Service) checkWebActor(ctx context.Context, tx pgx.Tx, hash string, d Device) error {
|
||||
w, err := s.webSession(ctx, tx, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if w.Device.ID != d.ID {
|
||||
return errAccess
|
||||
}
|
||||
setup, err := readSetup(ctx, tx, w.SetupID, d, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A closed setup can report a completed binding, but cannot authorize writes.
|
||||
if setup.Closed != nil || !setup.Expires.After(s.now()) {
|
||||
return fail(410, "SETUP_EXPIRED", "设置入口已关闭,请从设备列表查看结果")
|
||||
}
|
||||
if w.UserID != "" {
|
||||
var active bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM user_account WHERE id=$1::uuid AND tenant_id=$2::uuid AND status='active')`, w.UserID, d.TenantID).Scan(&active)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !active {
|
||||
return errAccess
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) checkCSRF(ctx context.Context, hash, token string) error {
|
||||
if !validB64(token, 32) {
|
||||
return errAccess
|
||||
}
|
||||
var expected string
|
||||
err := s.pool.QueryRow(ctx, `SELECT csrf_hash FROM onboarding_web_session WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now()`, hash).Scan(&expected)
|
||||
if err != nil || !hmac.Equal([]byte(expected), []byte(digest([]byte(token)))) {
|
||||
return errAccess
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ExchangeInput struct {
|
||||
Token string `json:"token"`
|
||||
Verifier string `json:"verifier"`
|
||||
}
|
||||
|
||||
func (s *Service) Exchange(ctx context.Context, in ExchangeInput) (BrowserCredentials, error) {
|
||||
var out BrowserCredentials
|
||||
if !validB64(in.Token, 32) || !validB64(in.Verifier, 32) {
|
||||
return out, errAccess
|
||||
}
|
||||
var deviceID string
|
||||
err := s.pool.QueryRow(ctx, `SELECT ss.device_id::text FROM device_handoff h JOIN device_setup_session ss ON ss.id=h.setup_id WHERE h.token_hash=$1`, digest([]byte(in.Token))).Scan(&deviceID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return out, errAccess
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
d, err := readDevice(ctx, tx, deviceID, true)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if !deviceAllowed(d) {
|
||||
return out, errAccess
|
||||
}
|
||||
var setupID, challenge string
|
||||
var expires time.Time
|
||||
var consumed *time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT setup_id::text,code_challenge,expires_at,consumed_at FROM device_handoff WHERE token_hash=$1 FOR UPDATE`, digest([]byte(in.Token))).Scan(&setupID, &challenge, &expires, &consumed)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(in.Verifier))
|
||||
if consumed != nil || !expires.After(s.now()) || !hmac.Equal([]byte(challenge), []byte(encode64(sum[:]))) {
|
||||
return out, fail(410, "HANDOFF_INVALID", "入口已失效,请从设备获取新入口")
|
||||
}
|
||||
setup, err := readSetup(ctx, tx, setupID, d, true)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if setup.Mode != "ENROLL" {
|
||||
return out, errAccess
|
||||
}
|
||||
st, err := s.state(ctx, tx, d)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if st.BindingState == "BOUND" {
|
||||
return out, errAccess
|
||||
}
|
||||
out.Token = randomToken(32)
|
||||
out.CSRF = randomToken(32)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO onboarding_web_session(token_hash,setup_id,csrf_hash,expires_at) VALUES($1,$2::uuid,$3,$4)`, digest([]byte(out.Token)), setupID, digest([]byte(out.CSRF)), setup.Expires)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_handoff SET consumed_at=now() WHERE token_hash=$1`, digest([]byte(in.Token)))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Data = map[string]any{"setup_id": setupID, "device_id": d.FactoryID, "state": st, "expires_at_ms": setup.Expires.UnixMilli(), "sms_enabled": s.options.SMSLoginEnabled}
|
||||
return out, tx.Commit(ctx)
|
||||
}
|
||||
func (s *Service) attachPrincipal(ctx context.Context, tx pgx.Tx, w WebSession, d Device, p identity.Principal, allowSignup bool) (BrowserCredentials, error) {
|
||||
var out BrowserCredentials
|
||||
var user string
|
||||
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
|
||||
return out, err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "account:"+d.TenantID+":"+p.Issuer+":"+p.Subject)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
err = tx.QueryRow(ctx, `SELECT id::text FROM user_account WHERE tenant_id=$1::uuid AND oidc_issuer=$2 AND oidc_subject=$3 AND status='active' FOR SHARE`, d.TenantID, p.Issuer, p.Subject).Scan(&user)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
var tenantPublic string
|
||||
if err = tx.QueryRow(ctx, `SELECT public_id::text FROM tenant WHERE id=$1::uuid`, d.TenantID).Scan(&tenantPublic); err != nil {
|
||||
return out, err
|
||||
}
|
||||
allowed := false
|
||||
for _, id := range s.options.SelfSignupTenantIDs {
|
||||
if id == tenantPublic {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
if !allowSignup || !allowed {
|
||||
return out, fail(403, "TENANT_MEMBERSHIP_REQUIRED", "请先由该租户管理员授权账号")
|
||||
}
|
||||
err = tx.QueryRow(ctx, `INSERT INTO user_account(tenant_id,oidc_issuer,oidc_subject,display_name,platform_role,account_kind) VALUES($1::uuid,$2,$3,$4,'customer','customer') RETURNING id::text`, d.TenantID, p.Issuer, p.Subject, p.DisplayName).Scan(&user)
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Token = randomToken(32)
|
||||
out.CSRF = randomToken(32)
|
||||
out.AccountToken = randomToken(32)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO onboarding_web_session(token_hash,setup_id,csrf_hash,user_id,issuer,subject,expires_at) VALUES($1,$2::uuid,$3,$4::uuid,$5,$6,$7)`, digest([]byte(out.Token)), w.SetupID, digest([]byte(out.CSRF)), user, p.Issuer, p.Subject, w.Expires)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE onboarding_web_session SET revoked_at=now() WHERE token_hash=$1`, w.Hash)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO onboarding_account_session(token_hash,csrf_hash,user_id,expires_at) VALUES($1,$2,$3::uuid,$4)`, digest([]byte(out.AccountToken)), digest([]byte(out.CSRF)), user, s.now().Add(24*time.Hour))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Data = map[string]any{"authenticated": true, "setup_id": w.SetupID}
|
||||
return out, nil
|
||||
}
|
||||
func (s *Service) AuthLink(ctx context.Context, w WebSession, p identity.Principal) (BrowserCredentials, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return BrowserCredentials{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
d, err := readDevice(ctx, tx, w.Device.ID, true)
|
||||
if err != nil {
|
||||
return BrowserCredentials{}, err
|
||||
}
|
||||
out, err := s.attachPrincipal(ctx, tx, w, d, p, true)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
type ClaimInput struct {
|
||||
ElderID string `json:"elder_id"`
|
||||
Generation int64 `json:"expected_binding_generation"`
|
||||
}
|
||||
|
||||
func (s *Service) Claim(ctx context.Context, w WebSession, requestID string, raw []byte, in ClaimInput) (map[string]any, error) {
|
||||
if w.UserID == "" {
|
||||
return nil, fail(401, "LOGIN_REQUIRED", "请先登录")
|
||||
}
|
||||
if !uuidValid(in.ElderID) || in.Generation < 0 {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "请选择使用者")
|
||||
}
|
||||
return s.mutate(ctx, w.Device, "web:"+w.Hash, "claim", requestID, raw, 120*time.Second, func(tx pgx.Tx, d Device) (map[string]any, error) {
|
||||
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.Generation != in.Generation {
|
||||
return nil, errState
|
||||
}
|
||||
setup, err := readSetup(ctx, tx, w.SetupID, d, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if setup.Mode != "ENROLL" {
|
||||
return nil, errAccess
|
||||
}
|
||||
var household, subject string
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,household_id::text FROM subject_profile WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND status='active'`, in.ElderID, d.TenantID).Scan(&subject, &household)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errAccess
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = claimPermission(ctx, tx, claimRecord{UserID: w.UserID, HouseholdID: household, SubjectID: subject, TenantID: d.TenantID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := s.state(ctx, tx, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st.BindingState == "BOUND" {
|
||||
return nil, errState
|
||||
}
|
||||
return newClaim(ctx, s, tx, d, setup.ID, w.UserID, household, subject)
|
||||
})
|
||||
}
|
||||
|
||||
type CancelInput struct {
|
||||
ClaimID string `json:"claim_id"`
|
||||
Generation int64 `json:"expected_binding_generation"`
|
||||
}
|
||||
|
||||
func (s *Service) Cancel(ctx context.Context, w WebSession, requestID string, raw []byte, in CancelInput) (map[string]any, error) {
|
||||
if !uuidV4(in.ClaimID) || in.Generation < 0 || w.UserID == "" {
|
||||
return nil, errAccess
|
||||
}
|
||||
return s.mutate(ctx, w.Device, "web:"+w.Hash, "cancel", requestID, raw, 24*time.Hour, func(tx pgx.Tx, d Device) (map[string]any, error) {
|
||||
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := readClaim(ctx, tx, in.ClaimID, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.UserID != w.UserID || c.SetupID != w.SetupID {
|
||||
return nil, errAccess
|
||||
}
|
||||
if err = claimPermission(ctx, tx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Status == "CONFIRMED" && d.Generation == c.Generation+1 {
|
||||
st, e := s.state(ctx, tx, d)
|
||||
return map[string]any{"already_bound": true, "state": st}, e
|
||||
}
|
||||
if d.Generation != in.Generation {
|
||||
return nil, errState
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE id=$1::uuid AND status='PENDING'`, c.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
if err = bump(ctx, tx, d.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
st, err := s.state(ctx, tx, d)
|
||||
return map[string]any{"state": st}, err
|
||||
})
|
||||
}
|
||||
func (s *Service) WebStatus(ctx context.Context, w WebSession) (map[string]any, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
d, err := readDevice(ctx, tx, w.Device.ID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !deviceAllowed(d) {
|
||||
return nil, errAccess
|
||||
}
|
||||
setup, err := readSetup(ctx, tx, w.SetupID, d, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := s.state(ctx, tx, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var binding *string
|
||||
var deviceSaved bool
|
||||
var mine *string
|
||||
if w.UserID != "" {
|
||||
err = tx.QueryRow(ctx, `SELECT b.public_id::text,EXISTS(SELECT 1 FROM device_state_ack a WHERE a.device_id=b.device_id AND a.binding_generation=b.generation AND a.boot_id=$3::uuid) FROM device_binding b JOIN household_member hm ON hm.household_id=b.household_id AND hm.user_id=$2::uuid AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' WHERE b.device_id=$1::uuid AND b.unbound_at IS NULL`, d.ID, w.UserID, d.BootID).Scan(&binding, &deviceSaved)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
err = tx.QueryRow(ctx, `SELECT id::text FROM device_binding_claim WHERE setup_id=$1::uuid AND user_id=$2::uuid AND status='PENDING' AND expires_at>now()`, w.SetupID, w.UserID).Scan(&mine)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Never expose a different household's progress through an old handoff.
|
||||
if st.BindingState == "BOUND" && binding == nil {
|
||||
return nil, errAccess
|
||||
}
|
||||
return map[string]any{"setup_id": w.SetupID, "state": st, "binding_id": binding, "claim_id": mine, "device_saved": deviceSaved, "authenticated": w.UserID != "", "closed": setup.Closed != nil || !setup.Expires.After(s.now()), "sms_enabled": s.options.SMSLoginEnabled, "expires_at_ms": setup.Expires.UnixMilli()}, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
type WebAckInput struct {
|
||||
BindingID string `json:"binding_id"`
|
||||
Generation int64 `json:"binding_generation"`
|
||||
Revision int64 `json:"received_revision"`
|
||||
}
|
||||
|
||||
func (s *Service) WebAck(ctx context.Context, w WebSession, requestID string, raw []byte, in WebAckInput) (map[string]any, error) {
|
||||
if !uuidValid(in.BindingID) || in.Generation < 1 || in.Revision < 1 || w.UserID == "" {
|
||||
return nil, errAccess
|
||||
}
|
||||
return s.mutate(ctx, w.Device, "web:"+w.Hash, "web-ack", requestID, raw, 24*time.Hour, func(tx pgx.Tx, d Device) (map[string]any, error) {
|
||||
var bindingID string
|
||||
err := tx.QueryRow(ctx, `SELECT b.id::text FROM device_binding b JOIN household_member hm ON hm.household_id=b.household_id AND hm.user_id=$3::uuid AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN device_state_snapshot ss ON ss.device_id=b.device_id AND ss.revision=$5 AND ss.binding_generation=b.generation AND ss.state->>'binding_state'='BOUND' WHERE b.device_id=$1::uuid AND b.public_id=$2::uuid AND b.unbound_at IS NULL AND b.generation=$4`, d.ID, in.BindingID, w.UserID, in.Generation, in.Revision).Scan(&bindingID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errState
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.Generation != in.Generation {
|
||||
return nil, errState
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE device_setup_session SET web_ack_binding_id=$2::uuid,web_ack_generation=$3,web_ack_revision=$4,web_ack_at=now() WHERE id=$1::uuid`, w.SetupID, bindingID, in.Generation, in.Revision)
|
||||
return map[string]any{"accepted": true}, err
|
||||
})
|
||||
}
|
||||
func (s *Service) Profiles(ctx context.Context, w WebSession) (map[string]any, error) {
|
||||
if w.UserID == "" {
|
||||
return nil, fail(401, "LOGIN_REQUIRED", "请先登录")
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT h.public_id::text,h.name,sp.public_id::text,sp.preferred_name FROM household_member hm JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id AND h.status='active' LEFT JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id AND sp.status='active' WHERE hm.user_id=$1::uuid AND hm.tenant_id=$2::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') ORDER BY h.name,sp.preferred_name`, w.UserID, w.Device.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var h, n string
|
||||
var id, name *string
|
||||
if err = rows.Scan(&h, &n, &id, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, map[string]any{"household_id": h, "household_name": n, "elder_id": id, "preferred_name": name})
|
||||
}
|
||||
return map[string]any{"items": items}, rows.Err()
|
||||
}
|
||||
|
||||
type ProfileInput struct {
|
||||
HouseholdID string `json:"household_id,omitempty"`
|
||||
HouseholdName string `json:"household_name,omitempty"`
|
||||
Name string `json:"preferred_name"`
|
||||
}
|
||||
|
||||
func (s *Service) CreateProfile(ctx context.Context, w WebSession, requestID string, raw []byte, in ProfileInput) (map[string]any, error) {
|
||||
if w.UserID == "" || !nonempty(in.Name, 120) || (in.HouseholdID == "" && !nonempty(in.HouseholdName, 200)) || (in.HouseholdID != "" && !uuidValid(in.HouseholdID)) {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "家庭和使用者信息不正确")
|
||||
}
|
||||
return s.mutate(ctx, w.Device, "web:"+w.Hash, "profile", requestID, raw, 24*time.Hour, func(tx pgx.Tx, d Device) (map[string]any, error) {
|
||||
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var household, public string
|
||||
var err error
|
||||
if in.HouseholdID == "" {
|
||||
err = tx.QueryRow(ctx, `INSERT INTO household(tenant_id,name,owner_user_id) VALUES($1::uuid,$2,$3::uuid) RETURNING id::text,public_id::text`, d.TenantID, in.HouseholdName, w.UserID).Scan(&household, &public)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO household_member(tenant_id,household_id,user_id,role) VALUES($1::uuid,$2::uuid,$3::uuid,'owner')`, d.TenantID, household, w.UserID)
|
||||
} else {
|
||||
err = tx.QueryRow(ctx, `SELECT h.id::text,h.public_id::text FROM household h JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id WHERE h.public_id=$1::uuid AND h.tenant_id=$2::uuid AND h.status='active' AND hm.user_id=$3::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') FOR SHARE OF h,hm`, in.HouseholdID, d.TenantID, w.UserID).Scan(&household, &public)
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errAccess
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var id string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO subject_profile(tenant_id,household_id,preferred_name) VALUES($1::uuid,$2::uuid,$3) RETURNING public_id::text`, d.TenantID, household, in.Name).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.auditEvent(ctx, tx, d, "user", accountPublicID(ctx, tx, w.UserID), "onboarding.profile.create", requestID, map[string]any{"subject_id": id}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"elder_id": id, "household_id": public}, nil
|
||||
})
|
||||
}
|
||||
func accountPublicID(ctx context.Context, q queryer, id string) string {
|
||||
var p string
|
||||
_ = q.QueryRow(ctx, `SELECT public_id::text FROM user_account WHERE id=$1::uuid`, id).Scan(&p)
|
||||
return p
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -398,6 +399,9 @@ func (s *Service) IssueDeviceClaim(ctx context.Context, principal identity.Princ
|
||||
}
|
||||
|
||||
func (s *Service) issueDeviceClaimTx(ctx context.Context, tx pgx.Tx, access operatorAccess, devicePublicID, deviceID, requestID string) (DeviceClaimIssue, error) {
|
||||
if err := devicebinding.RequireLegacy(ctx, tx, deviceID); err != nil {
|
||||
return DeviceClaimIssue{}, err
|
||||
}
|
||||
randomBytes := make([]byte, 10)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return DeviceClaimIssue{}, fmt.Errorf("generate device claim: %w", err)
|
||||
@@ -453,6 +457,11 @@ func (s *Service) Update(ctx context.Context, principal identity.Principal, tena
|
||||
return Device{}, ErrVersionConflict
|
||||
}
|
||||
model, modelSet := pointerValue(input.Model)
|
||||
if input.Model != nil || input.HardwareRevision != nil || input.VoiceRuntime != nil || (input.HouseholdID != nil && strings.TrimSpace(*input.HouseholdID) != "") {
|
||||
if err := devicebinding.RequireLegacy(ctx, tx, deviceID); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
}
|
||||
hardware, hardwareSet := pointerValue(input.HardwareRevision)
|
||||
security, securitySet := pointerValue(input.MinimumSecurityVersion)
|
||||
firmware, firmwareSet := pointerValue(input.MinimumFirmwareVersion)
|
||||
@@ -735,6 +744,13 @@ func (s *Service) RotateCredential(ctx context.Context, principal identity.Princ
|
||||
if err != nil {
|
||||
return CredentialIssue{}, err
|
||||
}
|
||||
var rotationDeviceID string
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, devicePublicID, access.tenantID).Scan(&rotationDeviceID); err != nil {
|
||||
return CredentialIssue{}, ErrNotFound
|
||||
}
|
||||
if err := devicebinding.RequireLegacy(ctx, tx, rotationDeviceID); err != nil {
|
||||
return CredentialIssue{}, err
|
||||
}
|
||||
version, err := s.credential.ProvisionTx(ctx, tx, devicePublicID, []byte(secret), true)
|
||||
if errors.Is(err, deviceauth.ErrDeviceNotFound) {
|
||||
return CredentialIssue{}, ErrNotFound
|
||||
@@ -786,6 +802,9 @@ func validateAssignment(ctx context.Context, q queryRow, tenantID, householdPubl
|
||||
}
|
||||
|
||||
func insertBinding(ctx context.Context, tx pgx.Tx, access operatorAccess, deviceID, householdPublicID, subjectPublicID, reason string) error {
|
||||
if err := devicebinding.RequireLegacy(ctx, tx, deviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason)
|
||||
SELECT $1::uuid,$2::uuid,h.id,sp.id,$5::uuid,$6 FROM household h JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id
|
||||
WHERE h.public_id=$3::uuid AND sp.public_id=$4::uuid AND h.tenant_id=$1::uuid`,
|
||||
|
||||
@@ -32,10 +32,12 @@ func (s *Service) CreateHardwareAgent(ctx context.Context, identity deviceauth.I
|
||||
request := idempotency.Request{TenantID: &tenantID, ActorKey: "device:" + identity.PublicID,
|
||||
Scope: "POST /device/v3/agent-sessions", Key: idempotencyKey,
|
||||
RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 24 * time.Hour}
|
||||
if cached, found, err := s.executor.Lookup(ctx, request); err != nil {
|
||||
return HardwareAgentCreateResponse{}, false, err
|
||||
} else if found {
|
||||
return s.finalizeHardwareCreate(cached)
|
||||
if identity.EnrollmentMode != "physical_confirm" {
|
||||
if cached, found, err := s.executor.Lookup(ctx, request); err != nil {
|
||||
return HardwareAgentCreateResponse{}, false, err
|
||||
} else if found {
|
||||
return s.finalizeHardwareCreate(cached)
|
||||
}
|
||||
}
|
||||
memoryVector, memoryPolicy, err := s.prepareMemoryQuery(ctx, identity, input.RequestedGoal)
|
||||
if err != nil {
|
||||
@@ -66,7 +68,7 @@ func (s *Service) CreateHardwareAgent(ctx context.Context, identity deviceauth.I
|
||||
}
|
||||
}
|
||||
}
|
||||
result, err := s.executor.Execute(ctx, request, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
result, err := s.executor.ExecuteGuarded(ctx, request, onboardingCreateGuard(identity, input), func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
now := s.now().UTC()
|
||||
created, internalID, createErr := s.repository.Create(operationContext, tx, identity, CreateInput{
|
||||
DeviceTime: input.DeviceTime, MonotonicMS: input.MonotonicMS, Network: input.Network,
|
||||
@@ -86,9 +88,18 @@ func (s *Service) CreateHardwareAgent(ctx context.Context, identity deviceauth.I
|
||||
if createErr != nil {
|
||||
return idempotency.Result{}, createErr
|
||||
}
|
||||
if err := attachOnboardingConversation(operationContext, tx, identity, input, internalID); err != nil {
|
||||
return idempotency.Result{}, err
|
||||
}
|
||||
if created.Hardware == nil {
|
||||
return idempotency.Result{}, errors.New("hardware agent runtime was not allocated")
|
||||
}
|
||||
if input.Intent == "activation_test" {
|
||||
created.Hardware.Session.MaximumDurationSeconds = 60
|
||||
if created.Hardware.Session.AllocationExpiresAt.After(now.Add(time.Minute)) {
|
||||
created.Hardware.Session.AllocationExpiresAt = now.Add(time.Minute)
|
||||
}
|
||||
}
|
||||
body, marshalErr := json.Marshal(created.Hardware)
|
||||
if marshalErr != nil {
|
||||
return idempotency.Result{}, marshalErr
|
||||
@@ -144,6 +155,9 @@ func (s *Service) ProcessHardwareAgentEvent(ctx context.Context, identity device
|
||||
return HardwareAgentEventResponse{}, err
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if err := guardOnboardingEvent(ctx, tx, identity, conversationPublicID, input); err != nil {
|
||||
return HardwareAgentEventResponse{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
var internalID, tenantID, providerSessionRef, nonceDigest, expectedBotID, expectedTransport, expectedConfigDigest string
|
||||
var lastEventSequence int64
|
||||
@@ -248,6 +262,9 @@ func (s *Service) ProcessHardwareAgentEvent(ctx context.Context, identity device
|
||||
currentStatus = conversation.StatusFailed
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET last_event_at=$2,last_event_sequence=GREATEST(last_event_sequence,$3),updated_at=$2 WHERE conversation_id=$1::uuid`, internalID, now, input.SequenceNo)
|
||||
if input.EventType == "first_audio_received" && currentStatus != conversation.StatusActive {
|
||||
return HardwareAgentEventResponse{}, ErrConversationStateConflict
|
||||
}
|
||||
if err == nil && currentStatus == conversation.StatusActive {
|
||||
leaseExpires := now.Add(time.Duration(s.options.HeartbeatInterval*5) * time.Second)
|
||||
_, err = tx.Exec(ctx, `UPDATE conversation SET lease_expires_at=$2,updated_at=$3,last_heartbeat_seq=GREATEST(last_heartbeat_seq,$4) WHERE id=$1::uuid AND status='ACTIVE'`, internalID, leaseExpires, now, input.SequenceNo)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrOnboardingState = errors.New("onboarding state or test permit is no longer valid")
|
||||
|
||||
func onboardingCreateGuard(identity deviceauth.Identity, input HardwareAgentCreateInput) idempotency.Guard {
|
||||
return func(ctx context.Context, tx pgx.Tx, cached *idempotency.Result) error {
|
||||
var mode, status, lifecycle string
|
||||
var generation int64
|
||||
err := tx.QueryRow(ctx, `SELECT enrollment_mode,status,activation_state,binding_generation FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&mode, &status, &lifecycle, &generation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mode == "legacy" {
|
||||
if input.Intent != "" || input.ActivationID != "" || input.AttemptID != "" || input.PermitToken != "" {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if identity.EnrollmentMode != "physical_confirm" || generation != identity.BindingGeneration || status != "registered" || lifecycle == "REVOKED" || lifecycle == "LOST" {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
var allowed bool
|
||||
err = tx.QueryRow(ctx, `SELECT true FROM device_binding b JOIN household h ON h.id=b.household_id AND h.status='active' JOIN subject_profile sp ON sp.id=b.subject_id AND sp.status='active' JOIN household_member hm ON hm.household_id=h.id AND hm.user_id=b.bound_by AND hm.status='active' AND hm.role IN ('owner','admin','editor') JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' WHERE b.device_id=$1::uuid AND b.unbound_at IS NULL AND b.generation=$2 FOR SHARE OF b,h,sp,hm,ua`, identity.ID, generation).Scan(&allowed)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if input.Intent == "" {
|
||||
if input.ActivationID != "" || input.AttemptID != "" || input.PermitToken != "" {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_activation_job WHERE device_id=$1::uuid AND binding_generation=$2 AND status='READY' AND config_ack_at IS NOT NULL AND config_expires_at>now())`, identity.ID, generation).Scan(&allowed)
|
||||
} else {
|
||||
if input.Intent != "activation_test" {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
if _, e := uuid.Parse(input.ActivationID); e != nil {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
if _, e := uuid.Parse(input.AttemptID); e != nil {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
hash := sha256.Sum256([]byte(input.PermitToken))
|
||||
var attached *string
|
||||
err = tx.QueryRow(ctx, `SELECT a.conversation_id::text FROM device_activation_job j JOIN device_activation_attempt a ON a.activation_id=j.id WHERE j.id=$1::uuid AND j.device_id=$2::uuid AND a.id=$3::uuid AND j.binding_generation=$4 AND a.binding_generation=$4 AND a.config_version=j.config_version AND j.status IN ('REGISTERED_OBSERVED','CONTACT_POSSIBLE','READY') AND j.config_ack_at IS NOT NULL AND j.engine_ready_at IS NOT NULL AND j.config_expires_at>now() AND a.stage='CONVERSATION_TEST' AND a.executor='DEVICE' AND a.status='CONTACT_POSSIBLE' AND (a.permit_hash IS NULL OR a.permit_hash=$5) AND (a.permit_expires_at>now() OR a.conversation_id IS NOT NULL) FOR UPDATE OF j,a`, input.ActivationID, identity.ID, input.AttemptID, generation, hex.EncodeToString(hash[:])).Scan(&attached)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allowed = true
|
||||
if cached != nil && (cached.ResourceID == nil || attached == nil || *cached.ResourceID != *attached) {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
if cached != nil {
|
||||
if cached.ResourceID == nil {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM conversation WHERE id=$1::uuid AND device_id=$2::uuid AND onboarding_generation=$3 AND status IN ('ROOM_ISSUED','ACTIVE') AND (activation_test_deadline IS NULL OR activation_test_deadline>now()))`, *cached.ResourceID, identity.ID, generation).Scan(&allowed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func attachOnboardingConversation(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, input HardwareAgentCreateInput, conversation string) error {
|
||||
if identity.EnrollmentMode != "physical_confirm" {
|
||||
return nil
|
||||
}
|
||||
if input.Intent == "activation_test" {
|
||||
tag, err := tx.Exec(ctx, `UPDATE device_activation_attempt SET conversation_id=$2::uuid WHERE id=$1::uuid AND conversation_id IS NULL AND permit_expires_at>now() AND status='CONTACT_POSSIBLE'`, input.AttemptID, conversation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE conversation SET onboarding_generation=$2,activation_attempt_id=$3::uuid,activation_test_deadline=now()+interval '60 seconds',allocation_expires_at=LEAST(allocation_expires_at,now()+interval '60 seconds') WHERE id=$1::uuid`, conversation, identity.BindingGeneration, input.AttemptID)
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `UPDATE conversation SET onboarding_generation=$2 WHERE id=$1::uuid`, conversation, identity.BindingGeneration)
|
||||
return err
|
||||
}
|
||||
func guardOnboardingEvent(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, conversation string, event HardwareAgentEventInput) error {
|
||||
if identity.EnrollmentMode != "physical_confirm" {
|
||||
return nil
|
||||
}
|
||||
var generation int64
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, `SELECT binding_generation,status FROM device WHERE id=$1::uuid FOR UPDATE`, identity.ID).Scan(&generation, &status); err != nil {
|
||||
return err
|
||||
}
|
||||
if generation != identity.BindingGeneration || status != "registered" {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
var stored *int64
|
||||
var deadline *time.Time
|
||||
err := tx.QueryRow(ctx, `SELECT onboarding_generation,activation_test_deadline FROM conversation WHERE public_id=$1::uuid AND device_id=$2::uuid`, conversation, identity.ID).Scan(&stored, &deadline)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stored == nil || *stored != generation {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
if deadline != nil && deadline.Before(time.Now()) && event.EventType != "session_ended" && event.EventType != "session_failed" {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -88,6 +88,7 @@ func (s *RecoveryScanner) ScanOnce(ctx context.Context) (int, error) {
|
||||
LEFT JOIN conversation_usage_budget cub ON cub.conversation_id=c.id AND cub.tenant_id=c.tenant_id
|
||||
WHERE (c.status='ROOM_ISSUED' AND c.allocation_expires_at <= $1)
|
||||
OR (c.status='ACTIVE' AND c.lease_expires_at IS NOT NULL AND c.lease_expires_at <= $1)
|
||||
OR (c.status='ACTIVE' AND c.activation_test_deadline IS NOT NULL AND c.activation_test_deadline <= $1)
|
||||
OR (c.status='ACTIVE' AND cub.in_progress_action='STOP_IMMEDIATELY' AND c.started_at IS NOT NULL AND c.started_at+make_interval(secs=>cub.max_call_duration_sec)<=$1)
|
||||
OR (c.status='STARTING_AGENT' AND c.updated_at <= $2)
|
||||
OR (c.status='STOPPING' AND c.updated_at <= $3)
|
||||
|
||||
@@ -57,6 +57,7 @@ type Privacy struct {
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
MaximumDurationSeconds int `json:"maximum_duration_seconds,omitempty"`
|
||||
EngineMode string `json:"engine_mode"`
|
||||
HeartbeatIntervalSecond int `json:"heartbeat_interval_sec"`
|
||||
AllocationExpiresAt time.Time `json:"allocation_expires_at"`
|
||||
@@ -93,6 +94,10 @@ type HardwareAgentCapabilities struct {
|
||||
}
|
||||
|
||||
type HardwareAgentCreateInput struct {
|
||||
Intent string `json:"intent,omitempty"`
|
||||
ActivationID string `json:"activation_id,omitempty"`
|
||||
AttemptID string `json:"attempt_id,omitempty"`
|
||||
PermitToken string `json:"permit_token,omitempty"`
|
||||
DeviceTime time.Time `json:"device_time"`
|
||||
MonotonicMS *int64 `json:"monotonic_ms,omitempty"`
|
||||
Capabilities HardwareAgentCapabilities `json:"capabilities"`
|
||||
|
||||
@@ -1640,7 +1640,7 @@ func collectBaseline(root string) ([]bundleFile, int, []string, error) {
|
||||
"docs/alert-operations.md", "docs/integration-diagnostics.md", "docs/content-safety-provider.md", "docs/remote-ci-supply-chain-evidence.md", "docs/real-device-rtc-evidence.md", "docs/real-agent-models-evidence.md", "docs/production-infrastructure-evidence.md", "docs/safari-compatibility-evidence.md", "docs/pilot-daily-operations.md", "docs/pilot-weekly-review.md",
|
||||
}
|
||||
paths := slices.Clone(required)
|
||||
for _, directory := range []string{"migrations", "deploy/kubernetes"} {
|
||||
for _, directory := range []string{"openapi", "migrations", "deploy/kubernetes"} {
|
||||
err := filepath.WalkDir(filepath.Join(root, directory), func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestBuildCreatesHashedBundleAndPreservesPendingGates(t *testing.T) {
|
||||
for _, entry := range reader.File {
|
||||
entries[entry.Name] = entry
|
||||
}
|
||||
for _, required := range []string{"release-manifest.json", "evidence-index.json", "acceptance/technical-acceptance-snapshot.json", "baseline/openapi/openapi.yaml", "evidence/REMOTE_CI_SUPPLY_CHAIN/01-ci-report.json"} {
|
||||
for _, required := range []string{"release-manifest.json", "evidence-index.json", "acceptance/technical-acceptance-snapshot.json", "baseline/openapi/openapi.yaml", "baseline/openapi/onboarding.yaml", "evidence/REMOTE_CI_SUPPLY_CHAIN/01-ci-report.json"} {
|
||||
if entries[required] == nil {
|
||||
t.Fatalf("bundle entry missing: %s", required)
|
||||
}
|
||||
@@ -442,7 +442,7 @@ func releaseFixture(t *testing.T, gateSatisfied bool) (string, string, string, s
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
files := []string{
|
||||
"openapi/openapi.yaml", "compose.yaml", ".github/workflows/ci.yml", "go.mod", "go.sum", "web/package.json", "web/package-lock.json",
|
||||
"openapi/openapi.yaml", "openapi/onboarding.yaml", "compose.yaml", ".github/workflows/ci.yml", "go.mod", "go.sum", "web/package.json", "web/package-lock.json",
|
||||
"docs/development-plan.md", "docs/development-status.md", "docs/requirements-traceability.md", "docs/external-acceptance-checklist.md",
|
||||
"docs/kubernetes-deployment.md", "docs/alert-operations.md", "docs/integration-diagnostics.md", "migrations/000001.up.sql", "deploy/kubernetes/base/api.yaml",
|
||||
"docs/content-safety-provider.md", "docs/remote-ci-supply-chain-evidence.md", "docs/real-device-rtc-evidence.md", "docs/real-agent-models-evidence.md", "docs/production-infrastructure-evidence.md", "docs/safari-compatibility-evidence.md", "docs/pilot-daily-operations.md", "docs/pilot-weekly-review.md",
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -72,6 +73,11 @@ func (s *Service) ChangeDeviceBinding(ctx context.Context, principal identity.Pr
|
||||
return err
|
||||
}
|
||||
var bindingID string
|
||||
if input.Action == DeviceBindingActionTransfer {
|
||||
if err = devicebinding.RequireLegacy(ctx, tx, access.deviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `SELECT public_id::text FROM device_binding WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND unbound_at IS NULL FOR UPDATE`, access.deviceID, access.tenantID).Scan(&bindingID); errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrDeviceBindingStale
|
||||
} else if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -57,6 +58,9 @@ func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal,
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("resolve device claim: %w", err)
|
||||
}
|
||||
if err = devicebinding.RequireLegacy(ctx, tx, deviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if revokedAt != nil || !expiresAt.After(time.Now()) {
|
||||
return ErrDeviceClaimInvalid
|
||||
}
|
||||
@@ -70,7 +74,10 @@ func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal,
|
||||
JOIN device d ON d.id=b.device_id AND d.tenant_id=b.tenant_id
|
||||
JOIN household h ON h.id=b.household_id AND h.tenant_id=b.tenant_id
|
||||
JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id
|
||||
WHERE b.id=$1::uuid`, *usedBindingID).Scan(&result.BindingID, &result.DeviceID, &result.HouseholdID, &result.SubjectID, &result.BoundAt); err != nil {
|
||||
WHERE b.id=$1::uuid AND b.unbound_at IS NULL AND b.generation=d.binding_generation`, *usedBindingID).Scan(&result.BindingID, &result.DeviceID, &result.HouseholdID, &result.SubjectID, &result.BoundAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrDeviceClaimUsed
|
||||
}
|
||||
return fmt.Errorf("read used device claim binding: %w", err)
|
||||
}
|
||||
if result.HouseholdID != householdPublicID || result.SubjectID != subjectPublicID {
|
||||
|
||||
@@ -364,6 +364,7 @@ func TestDeviceClaimIsOneTimeAndRetrySafe(t *testing.T) {
|
||||
if bindingID != "" {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_binding WHERE public_id=$1::uuid`, bindingID)
|
||||
}
|
||||
_, _ = pool.Exec(ctx,`UPDATE device SET activation_state='CLAIMABLE' WHERE public_id='10000000-0000-0000-0000-000000000512'::uuid AND NOT EXISTS(SELECT 1 FROM device_binding WHERE device_id=device.id AND unbound_at IS NULL)`)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id='saas-integration-device-claim'`)
|
||||
}()
|
||||
if _, _, err := service.ClaimDevice(ctx, outsider, claimCode, householdID, subjectID, "saas-integration-outsider-claim"); !errors.Is(err, ErrAccessDenied) {
|
||||
|
||||
Reference in New Issue
Block a user