Files
story/internal/onboarding/service.go
T

308 lines
12 KiB
Go
Raw Normal View History

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)
}