feat: complete device onboarding and mobile binding workflows
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-12 08:57:29 +08:00
parent 7846f30d0b
commit 98fb2965d0
116 changed files with 15669 additions and 64 deletions
+82
View File
@@ -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)) }
+184
View File
@@ -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, &timestamp, &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
}
+48
View File
@@ -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
}
+158
View File
@@ -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
}
+35
View File
@@ -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)
}
}
}
+423
View File
@@ -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
}
+222
View File
@@ -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")
}
}
+42
View File
@@ -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)
}
+126
View File
@@ -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, &currentJob))
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)
}
+444
View File
@@ -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", "接口不存在")
}
+407
View File
@@ -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")
}
}
+299
View File
@@ -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)
}
+142
View File
@@ -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)
}
+84
View File
@@ -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
}
+65
View File
@@ -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
}
+271
View File
@@ -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
}
+137
View File
@@ -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")
}
}
+158
View File
@@ -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
}
+37
View File
@@ -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
}
+93
View File
@@ -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(&current)
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(&current)
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
}
+307
View File
@@ -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)
}
+518
View File
@@ -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 }
+240
View File
@@ -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
}
+437
View File
@@ -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
}