feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+151
View File
@@ -0,0 +1,151 @@
package onboarding
import (
"context"
"errors"
"time"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func bleClaimPermission(ctx context.Context, tx pgx.Tx, c bleClaim) error {
if c.SubjectID != "" {
return claimPermission(ctx, tx, c.claimRecord)
}
var active bool
err := tx.QueryRow(ctx, `SELECT true FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ua.id=$1::uuid AND ua.tenant_id=$2::uuid AND ua.status='active' AND ua.account_kind='customer' FOR SHARE OF ua,t`, c.UserID, c.TenantID).Scan(&active)
if errors.Is(err, pgx.ErrNoRows) {
return errAccess
}
return err
}
// This runs only after the authenticated device submits the physically confirmed token.
func (s *Service) commitBLEAccount(ctx context.Context, tx pgx.Tx, d Device, c bleClaim, requestID string) (map[string]any, error) {
var owned bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND released_at IS NULL)`, d.ID).Scan(&owned); err != nil {
return nil, err
}
if owned {
return nil, fail(409, "ALREADY_BOUND", "设备已添加到账号")
}
var ownerID string
err := tx.QueryRow(ctx, `INSERT INTO device_account_binding(device_id,tenant_id,user_id,generation) VALUES($1::uuid,$2::uuid,$3::uuid,$4) RETURNING id::text`, d.ID, d.TenantID, c.UserID, d.Generation+1).Scan(&ownerID)
if err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `UPDATE device SET binding_generation=binding_generation+1,onboarding_revision=onboarding_revision+1 WHERE id=$1::uuid`, d.ID); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='COMMITTED',account_binding_id=$2::uuid,completed_at=now() WHERE id=$1::uuid`, c.ID, ownerID); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, d.ID); 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
}
// Preserve the existing device response (including the five provider fields).
// Chat still requires a real subject binding; account ownership alone cannot start it.
if err = s.enqueueActivation(ctx, tx, d, d.Generation+1, requestID); err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "device", d.PublicID, "device.account.ble", requestID, map[string]any{"account_binding_id": ownerID, "binding_generation": d.Generation + 1}); err != nil {
return nil, err
}
return map[string]any{"status": "success"}, tx.Commit(ctx)
}
type AssignDeviceSubjectInput struct {
DeviceID string `json:"device_id"`
ElderID string `json:"elder_id"`
ExpectedAccountBindingID string `json:"expected_account_binding_id"`
}
// Account ownership already proves physical possession; later subject selection
// requires both that same owner and management access to the selected family.
func (s *Service) AssignDeviceSubject(ctx context.Context, p identity.Principal, requestID string, in AssignDeviceSubjectInput) (map[string]any, error) {
if !uuidValid(in.DeviceID) || !uuidValid(in.ElderID) || !uuidValid(in.ExpectedAccountBindingID) || !uuidV4(requestID) {
return nil, fail(400, "VALIDATION_FAILED", "设备、老人或归属版本不正确")
}
if !s.Enabled() || s.options.PauseClaims {
return nil, fail(503, "ENROLLMENT_PAUSED", "设备绑定暂时暂停")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var deviceID string
err = tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid`, in.DeviceID).Scan(&deviceID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
d, err := readDevice(ctx, tx, deviceID, true)
if err != nil {
return nil, err
}
if !deviceAllowed(d) {
return nil, errAccess
}
var userID, userPublicID, assignedBindingID string
err = tx.QueryRow(ctx, `SELECT ua.id::text,ua.public_id::text,COALESCE(ab.assigned_binding_id::text,'') FROM device_account_binding ab JOIN user_account ua ON ua.id=ab.user_id AND ua.tenant_id=ab.tenant_id JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ab.id=$1::uuid AND ab.device_id=$2::uuid AND ((ab.released_at IS NULL AND ab.generation=$3) OR EXISTS(SELECT 1 FROM device_binding b JOIN subject_profile sp ON sp.id=b.subject_id WHERE b.id=ab.assigned_binding_id AND b.unbound_at IS NULL AND b.generation=$3 AND sp.public_id=$6::uuid)) AND ua.oidc_issuer=$4 AND ua.oidc_subject=$5 AND ua.status='active' AND ua.account_kind='customer' FOR SHARE OF ua,t`, in.ExpectedAccountBindingID, d.ID, d.Generation, p.Issuer, p.Subject, in.ElderID).Scan(&userID, &userPublicID, &assignedBindingID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
c := bleClaim{claimRecord: claimRecord{ID: uuid.NewString(), DeviceID: d.ID, TenantID: d.TenantID, UserID: userID, Generation: d.Generation, Expires: s.now().Add(5 * time.Minute)}, BindingStatus: "PENDING"}
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 errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
if err = claimPermission(ctx, tx, c.claimRecord); err != nil {
return nil, err
}
if assignedBindingID != "" {
return map[string]any{"status": "success", "device_id": in.DeviceID, "elder_id": in.ElderID}, tx.Commit(ctx)
}
// Expire stale legacy setup claims before inserting the assignment claim.
if err = s.expireClaims(ctx, tx, d.ID); err != nil {
return nil, err
}
var busy bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING') OR EXISTS(SELECT 1 FROM fleet_ota WHERE device_id=$1::uuid AND status IN ('pending','accepted'))`, d.ID).Scan(&busy)
if err != nil {
return nil, err
}
if busy {
return nil, fail(409, "DEVICE_BUSY", "设备有待确认操作或升级,请完成后重试")
}
token := randomToken(32)
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND user_id=$2::uuid AND status='PENDING'`, d.ID, userID); err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `INSERT INTO device_ble_binding(id,token_hash,device_id,tenant_id,user_id,household_id,subject_id,expected_generation,expires_at) VALUES($1::uuid,$2,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7::uuid,$8,$9)`, c.ID, digest([]byte(token)), d.ID, d.TenantID, userID, c.HouseholdID, c.SubjectID, d.Generation, c.Expires)
if err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `SELECT set_config('app.device_account_binding',$1,true)`, in.ExpectedAccountBindingID); err != nil {
return nil, err
}
c.AccountBindingID = in.ExpectedAccountBindingID
if _, err = s.commitBLESubject(ctx, tx, d, c, requestID, token); err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "user", userPublicID, "device.account.assign_subject", requestID, map[string]any{"elder_id": in.ElderID, "account_binding_id": in.ExpectedAccountBindingID}); err != nil {
return nil, err
}
return map[string]any{"status": "success", "device_id": in.DeviceID, "elder_id": in.ElderID}, tx.Commit(ctx)
}
+198
View File
@@ -0,0 +1,198 @@
package onboarding
import (
"sync"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/devicebinding"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/saas"
"github.com/google/uuid"
)
func accountOnlyUser(t *testing.T, f *fixture) identity.Principal {
t.Helper()
p := identity.Principal{Issuer: f.user.Issuer, Subject: uuid.NewString(), DisplayName: "新账号"}
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO user_account(tenant_id,oidc_issuer,oidc_subject,display_name,account_kind) VALUES($1::uuid,$2,$3,'新账号','customer')`, f.tenant, p.Issuer, p.Subject)
must(t, err)
return p
}
func accountToken(t *testing.T, f *fixture, p identity.Principal) string {
t.Helper()
out, err := f.s.CreateBLEToken(f.ctx, p, BLETokenInput{DeviceID: f.d.FactoryID})
must(t, err)
return out["bind_token"].(string)
}
func TestAccountFirstBindingThenCreateFamilyAndAssign(t *testing.T) {
for _, transport := range []string{"mini", "web"} {
t.Run(transport, func(t *testing.T) { testAccountFirstBindingThenCreateFamilyAndAssign(t, transport) })
}
}
func testAccountFirstBindingThenCreateFamilyAndAssign(t *testing.T, transport string) {
f := serverFixture(t)
p := accountOnlyUser(t, f)
other := accountOnlyUser(t, f)
f.web.Hash = ""
api := saas.NewService(f.s.pool, audit.NewRepository(), f.s.redis, nil)
state, err := f.s.BLEDeviceState(f.ctx, p, BLEDeviceInput{DeviceID: f.d.FactoryID})
must(t, err)
if state["bound"] != false {
t.Fatal(state)
}
token := accountToken(t, f, p)
// Another customer's pending token must not win after physical confirmation.
otherToken := accountToken(t, f, other)
out, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
if len(out) != 6 || out["RTCAppID"] != "test-app" {
t.Fatal("existing firmware needs five real provider fields")
}
f.refresh()
var families, subjects int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT (SELECT count(*) FROM household_member hm JOIN user_account ua ON ua.id=hm.user_id WHERE ua.oidc_subject=$1),(SELECT count(*) FROM subject_profile sp JOIN user_account ua ON ua.id=sp.created_by WHERE ua.oidc_subject=$1)`, p.Subject).Scan(&families, &subjects))
if families != 0 || subjects != 0 {
t.Fatal("claim fabricated profiles", families, subjects)
}
devices, err := api.ListDevices(f.ctx, p)
must(t, err)
if len(devices) != 1 || devices[0].AccountBindingID == "" || devices[0].SubjectID != "" {
t.Fatal(devices)
}
owned := devices[0]
_, err = api.GetDeviceTwin(f.ctx, p, f.d.PublicID)
must(t, err)
if _, err = api.GetDevice(f.ctx, other, f.d.PublicID); err == nil {
t.Fatal("other account read device")
}
if _, err = f.s.BLEDeviceState(f.ctx, other, BLEDeviceInput{DeviceID: f.d.FactoryID}); err == nil {
t.Fatal("other account can reconfigure Wi-Fi")
}
if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: otherToken}); err == nil {
t.Fatal("competing token stole ownership")
}
if err = devicebinding.RequireUnboundForReset(f.ctx, f.s.pool, f.d.ID); err == nil {
t.Fatal("reset bypassed owner removal")
}
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
if f.registrar.calls.Load() != 1 {
t.Fatal("duplicate supplier registration")
}
chat := callmemory.NewService(f.s.pool, nil)
_, err = chat.DirectRequest(f.ctx, deviceauth.Identity{ID: f.d.ID, PublicID: f.d.PublicID, TenantID: f.d.TenantID}, "start", callmemory.DirectInput{SessionID: uuid.NewString()})
if err == nil || err.Error() != "DEVICE_UNBOUND" {
t.Fatal("chat admitted without subject", err)
}
h, err := api.CreateHousehold(f.ctx, p, f.tenantPublic, "稍后创建的家庭", uuid.NewString())
must(t, err)
sp, err := api.CreateSubject(f.ctx, p, h.ID, uuid.NewString(), saas.CreateSubjectInput{PreferredName: "奶奶", Language: "zh-CN", Timezone: "Asia/Shanghai", ProfileData: map[string]any{}, ConversationPreferences: map[string]any{}})
must(t, err)
in := AssignDeviceSubjectInput{DeviceID: f.d.PublicID, ElderID: sp.ID, ExpectedAccountBindingID: owned.AccountBindingID}
if _, err = f.s.AssignDeviceSubject(f.ctx, other, uuid.NewString(), in); err == nil {
t.Fatal("other owner assigned subject")
}
bad := in
bad.ElderID = f.elder
if _, err = f.s.AssignDeviceSubject(f.ctx, p, uuid.NewString(), bad); err == nil {
t.Fatal("unmanaged family accepted")
}
session := randomToken(32)
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO onboarding_account_session(token_hash,csrf_hash,user_id,expires_at) SELECT $1,$2,id,now()+interval '1 hour' FROM user_account WHERE oidc_issuer=$3 AND oidc_subject=$4`, digest([]byte(session)), digest([]byte(webBLECSRF)), p.Issuer, p.Subject)
must(t, err)
status, _ := miniRequest(t, f.s, "POST", "/api/v1/mini/device-subject", "", in)
if status != 401 {
t.Fatal("anonymous assignment", status)
}
var data map[string]any
if transport == "web" {
status, data = webBLERequest(t, f.s, "device-subject", session, webBLECSRF, "https://platform.example", in)
} else {
status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/device-subject", "Mini customer."+session, in)
}
if status != 200 || data["elder_id"] != sp.ID {
t.Fatal("mini assignment failed", status, data)
}
f.refresh()
must(t, f.s.ProcessActivation(f.ctx, f.event()))
devices, err = api.ListDevices(f.ctx, p)
must(t, err)
if len(devices) != 1 || devices[0].SubjectID != sp.ID || devices[0].AccountBindingID != "" {
t.Fatal("assignment not visible", devices)
}
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
if f.registrar.calls.Load() != 1 {
t.Fatal("assignment registered device twice")
}
_, err = f.s.AssignDeviceSubject(f.ctx, p, uuid.NewString(), in)
must(t, err)
bad = in
bad.ElderID = f.elder
if _, err = f.s.AssignDeviceSubject(f.ctx, p, uuid.NewString(), bad); err == nil {
t.Fatal("stale assignment changed subject")
}
}
func TestAccountFirstConcurrentClaimReleaseAndReplay(t *testing.T) {
f := serverFixture(t)
f.web.Hash = ""
p := accountOnlyUser(t, f)
token := accountToken(t, f, p)
var wg sync.WaitGroup
errs := make(chan error, 2)
// Commit concurrently without contacting the supplier; both must be idempotent.
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := f.s.commitBLEBinding(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
errs <- err
}()
}
wg.Wait()
close(errs)
for err := range errs {
must(t, err)
}
f.refresh()
api := saas.NewService(f.s.pool, audit.NewRepository(), f.s.redis, nil)
devices, err := api.ListDevices(f.ctx, p)
must(t, err)
if len(devices) != 1 {
t.Fatal(devices)
}
impact, err := api.GetDeviceBindingImpact(f.ctx, p, f.d.PublicID, "unbind", "")
must(t, err)
if !impact.Allowed {
t.Fatal(impact)
}
_, err = api.ChangeDeviceBinding(f.ctx, p, f.d.PublicID, uuid.NewString(), saas.ChangeDeviceBindingInput{Action: "unbind", ExpectedBindingID: impact.BindingID, Acknowledged: true, Reason: "移除测试设备"})
must(t, err)
f.refresh()
devices, err = api.ListDevices(f.ctx, p)
must(t, err)
if len(devices) != 0 {
t.Fatal("released device still visible")
}
if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil {
t.Fatal("old token restored removed owner")
}
state, err := f.s.DeviceBLEState(f.ctx, f.d)
must(t, err)
if state["bound"] != false {
t.Fatal(state)
}
if err = devicebinding.RequireUnboundForReset(f.ctx, f.s.pool, f.d.ID); err != nil {
t.Fatal(err)
}
newOwner := accountOnlyUser(t, f)
token = accountToken(t, f, newOwner)
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
}
@@ -0,0 +1,73 @@
package onboarding
import (
"context"
"data-terminal-cloud/internal/audit"
"errors"
"github.com/jackc/pgx/v5"
)
// ProvisionCustomerPhone is an administrative migration, deliberately absent from HTTP routes.
// The operator must establish the old identity and authorize the phone association beforehand.
// It creates only a credential mapping: no user, household, role or SMS-verification state is changed.
func (s *Service) ProvisionCustomerPhone(ctx context.Context, userID, issuer, subject, phone, password, requestID string) (bool, error) {
return s.provisionPhone(ctx, userID, issuer, subject, phone, password, requestID)
}
func (s *Service) provisionPhone(ctx context.Context, userID, issuer, subject, phone, password, requestID string) (bool, error) {
if !uuidValid(userID) || issuer == "" || subject == "" || !phonePattern.MatchString(phone) || !validNewPassword(password) || !uuidV4(requestID) {
return false, errors.New("invalid phone provisioning input")
}
phoneHash := domainMAC(s.crypt.hashKey, "PHONE1\n"+phone)
tx, err := s.pool.Begin(ctx)
if err != nil {
return false, err
}
defer tx.Rollback(ctx)
var tenant, publicID string
err = tx.QueryRow(ctx, `SELECT ua.tenant_id::text,ua.public_id::text FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ua.id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' AND `+s.accountPredicate()+` FOR SHARE OF ua,t`, userID, issuer, subject).Scan(&tenant, &publicID)
if errors.Is(err, pgx.ErrNoRows) {
return false, errors.New("expected active account identity does not match")
}
if err != nil {
return false, err
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "admin-phone:"+phoneHash); err != nil {
return false, err
}
var existingPhone, existingIssuer, existingSubject, encoded string
err = tx.QueryRow(ctx, `SELECT phone_digest,issuer,subject,COALESCE(password_hash,'') FROM `+s.phoneCredentialTable()+` WHERE phone_digest=$1 OR (issuer=$2 AND subject=$3) ORDER BY phone_digest LIMIT 1 FOR UPDATE`, phoneHash, issuer, subject).Scan(&existingPhone, &existingIssuer, &existingSubject, &encoded)
if err == nil {
if existingPhone != phoneHash || existingIssuer != issuer || existingSubject != subject {
return false, errors.New("phone or identity is already bound; no changes made")
}
ok, e := verifyPassword(ctx, encoded, password)
if e != nil {
return false, e
}
if !ok {
return false, errors.New("credential already exists; refusing to replace its password")
}
return false, tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
return false, err
}
encoded, err = hashPassword(ctx, password)
if err != nil {
return false, err
}
masked := phone[:len(phone)-8] + "****" + phone[len(phone)-4:]
_, err = tx.Exec(ctx, `INSERT INTO `+s.phoneCredentialTable()+`(phone_digest,phone_masked,issuer,subject,password_hash) VALUES($1,$2,$3,$4,$5)`, phoneHash, masked, issuer, subject, encoded)
if err != nil {
return false, err
}
action := "customer.phone.provision"
if s.operatorLogin {
action = "operator.phone.provision"
}
err = s.audit.Record(ctx, tx, audit.Event{TenantID: &tenant, ActorType: "system", Action: action, ResourceType: "user_account", ResourceID: &publicID, RequestID: requestID, Purpose: "authorized account migration", Result: "success", Detail: map[string]any{"phone_masked": masked, "sms_verified": false}})
if err != nil {
return false, err
}
return true, tx.Commit(ctx)
}
@@ -0,0 +1,41 @@
package onboarding
import (
"github.com/google/uuid"
"testing"
)
func TestAdminPhoneProvisionPreservesIdentityAndRefusesConflicts(t *testing.T) {
f := newLoginFixture(t)
const user = "10000000-0000-0000-0000-000000000101"
const issuer = "http://localhost:58180/realms/voice-local"
const subject = "00000000-0000-0000-0000-000000000101"
created, err := f.s.ProvisionCustomerPhone(f.ctx, user, issuer, subject, f.phone, "InitialOnly123!", uuid.NewString())
must(t, err)
if !created {
t.Fatal("not created")
}
created, err = f.s.ProvisionCustomerPhone(f.ctx, user, issuer, subject, f.phone, "InitialOnly123!", uuid.NewString())
must(t, err)
if created {
t.Fatal("retry changed credential")
}
if _, err = f.s.ProvisionCustomerPhone(f.ctx, user, issuer, subject, f.phone, "DifferentOnly123!", uuid.NewString()); err == nil {
t.Fatal("password overwritten")
}
if _, err = f.s.ProvisionCustomerPhone(f.ctx, user, issuer, "wrong-identity", f.phone, "InitialOnly123!", uuid.NewString()); err == nil {
t.Fatal("wrong identity accepted")
}
// The existing customer identity is used even when login starts with SMS.
account, csrf := f.smsAccount()
p, err := f.s.AccountPrincipal(f.ctx, account.Value, csrf, true)
must(t, err)
if p.Issuer != issuer || p.Subject != subject {
t.Fatal("SMS created a second identity")
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM customer_phone_credential WHERE issuer=$1 AND subject=$2`, issuer, subject).Scan(&count))
if count != 1 {
t.Fatal("duplicate credential")
}
}
+409
View File
@@ -0,0 +1,409 @@
package onboarding
import (
"context"
"errors"
"net/http"
"strings"
"time"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type BLETokenInput struct {
DeviceID string `json:"device_id"`
ElderID string `json:"elder_id,omitempty"`
ExpectedBindingID string `json:"expected_binding_id,omitempty"`
}
type BLEBindInput struct {
Token string `json:"bind_token"`
}
type bleClaim struct {
claimRecord
AccountBindingID string
BindingStatus string
}
func readBLEClaim(ctx context.Context, tx pgx.Tx, token, deviceID string) (bleClaim, error) {
var c bleClaim
err := tx.QueryRow(ctx, `SELECT id::text,device_id::text,tenant_id::text,user_id::text,COALESCE(household_id::text,''),COALESCE(subject_id::text,''),expected_generation,status,expires_at,COALESCE(binding_id::text,''),COALESCE(account_binding_id::text,'') FROM device_ble_binding WHERE token_hash=$1 AND device_id=$2::uuid FOR UPDATE`, digest([]byte(token)), deviceID).Scan(&c.ID, &c.DeviceID, &c.TenantID, &c.UserID, &c.HouseholdID, &c.SubjectID, &c.Generation, &c.BindingStatus, &c.Expires, &c.BindingID, &c.AccountBindingID)
if errors.Is(err, pgx.ErrNoRows) {
return c, fail(404, "BIND_TOKEN_INVALID", "绑定申请不存在或不可访问")
}
return c, err
}
func (s *Service) bleWebHTTP(r *http.Request) (any, error) {
if r.URL.RawQuery != "" {
return nil, errAccess
}
p, err := s.browserPrincipal(r, true)
if err != nil {
return nil, err
}
if err = s.limit(r.Context(), "ble-account:"+p.Issuer+":"+p.Subject, 90, time.Minute); err != nil {
return nil, err
}
raw, err := readBody(r)
if err != nil {
return nil, err
}
if strings.HasSuffix(r.URL.Path, "ble-device-state") {
return decodeCall(raw, func(in BLEDeviceInput) (map[string]any, error) {
return s.BLEDeviceState(r.Context(), p, in)
})
}
if strings.HasSuffix(r.URL.Path, "device-subject") {
return decodeCall(raw, func(in AssignDeviceSubjectInput) (map[string]any, error) {
return s.AssignDeviceSubject(r.Context(), p, r.Header.Get("X-Request-ID"), in)
})
}
if strings.HasSuffix(r.URL.Path, "ble-bind-tokens") {
var in BLETokenInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
return s.CreateBLEToken(r.Context(), p, in)
}
var in BLEBindInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
return s.BLEStatus(r.Context(), p, in)
}
func (s *Service) CreateBLEToken(ctx context.Context, p identity.Principal, in BLETokenInput) (map[string]any, error) {
if !s.Enabled() || s.options.PauseClaims {
return nil, fail(503, "ENROLLMENT_PAUSED", "设备绑定暂时暂停")
}
if in.ElderID != "" && !uuidValid(in.ElderID) {
return nil, fail(400, "ELDER_ID_INVALID", "老人编号格式不正确,请重新选择老人")
}
if in.ExpectedBindingID != "" && (!uuidValid(in.ExpectedBindingID) || in.ElderID == "") {
return nil, fail(400, "BINDING_INVALID", "更换使用老人需要原绑定版本和新的老人")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err = lockBLEControl(ctx, tx, "", in.DeviceID); err != nil {
return nil, err
}
a, err := readBLEDeviceAccess(ctx, tx, p, in.DeviceID, true)
if err != nil {
return nil, err
}
d := a.Device
var user string
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' AND account_kind='customer' FOR SHARE`, d.TenantID, p.Issuer, p.Subject).Scan(&user)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
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) OR EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND released_at IS NULL)`, d.ID).Scan(&bound); err != nil {
return nil, err
}
if bound && in.ExpectedBindingID == "" {
return nil, fail(409, "ALREADY_BOUND", "设备已有归属,无需重复添加;如需更换 Wi-Fi,请使用重新配网")
}
var sourceBindingID string
if in.ExpectedBindingID != "" {
err = tx.QueryRow(ctx, `SELECT id::text FROM device_binding WHERE device_id=$1::uuid AND public_id=$2::uuid AND unbound_at IS NULL`, d.ID, in.ExpectedBindingID).Scan(&sourceBindingID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, fail(409, "BINDING_CHANGED", "设备归属已变化,请刷新后重新选择")
}
if err != nil {
return nil, err
}
}
// Omitting elder_id claims the device for this account without creating profiles.
if in.ElderID == "" {
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND user_id=$2::uuid AND status='PENDING'`, d.ID, user); err != nil {
return nil, err
}
token := randomToken(32)
_, err = tx.Exec(ctx, `INSERT INTO device_ble_binding(token_hash,device_id,tenant_id,user_id,expected_generation,expires_at) VALUES($1,$2::uuid,$3::uuid,$4::uuid,$5,$6)`, digest([]byte(token)), d.ID, d.TenantID, user, d.Generation, s.now().Add(5*time.Minute))
if err != nil {
return nil, err
}
return map[string]any{"bind_token": token}, tx.Commit(ctx)
}
// Explicit elder_id retains the existing one-step binding contract.
rows, err := tx.Query(ctx, `SELECT sp.id::text,sp.household_id::text FROM subject_profile sp JOIN household_member hm ON hm.household_id=sp.household_id AND hm.tenant_id=sp.tenant_id JOIN household h ON h.id=sp.household_id AND h.status='active' WHERE sp.tenant_id=$1::uuid AND hm.user_id=$2::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') AND sp.status='active' AND ($3='' OR sp.public_id::text=$3) ORDER BY sp.id LIMIT 2`, d.TenantID, user, in.ElderID)
if err != nil {
return nil, err
}
var candidates []claimRecord
for rows.Next() {
c := claimRecord{UserID: user, TenantID: d.TenantID}
if err = rows.Scan(&c.SubjectID, &c.HouseholdID); err != nil {
rows.Close()
return nil, err
}
candidates = append(candidates, c)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
if len(candidates) != 1 {
return nil, fail(400, "PROFILE_REQUIRED", "请选择一个有管理权限的使用者")
}
c := candidates[0]
if err = claimPermission(ctx, tx, c); err != nil {
return nil, err
}
if sourceBindingID != "" {
if err = s.checkBLETransfer(ctx, tx, d, c, sourceBindingID); err != nil {
return nil, err
}
}
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND user_id=$2::uuid AND status='PENDING'`, d.ID, user); err != nil {
return nil, err
}
token := randomToken(32)
// For a pending transfer binding_id pins the source; commit replaces it with
// the new binding. A normal add keeps it NULL until physical confirmation.
_, err = tx.Exec(ctx, `INSERT INTO device_ble_binding(token_hash,device_id,tenant_id,user_id,household_id,subject_id,expected_generation,expires_at,binding_id) VALUES($1,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7,$8,NULLIF($9,'')::uuid)`, digest([]byte(token)), d.ID, d.TenantID, user, c.HouseholdID, c.SubjectID, d.Generation, s.now().Add(5*time.Minute), sourceBindingID)
if err != nil {
return nil, err
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
return map[string]any{"bind_token": token}, nil
}
func (s *Service) BLEStatus(ctx context.Context, p identity.Principal, in BLEBindInput) (map[string]any, error) {
if !validB64(in.Token, 32) {
return nil, fail(400, "VALIDATION_FAILED", "绑定令牌格式不正确")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var deviceID string
err = tx.QueryRow(ctx, `SELECT b.device_id::text FROM device_ble_binding b JOIN user_account ua ON ua.id=b.user_id AND ua.status='active' WHERE b.token_hash=$1 AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, digest([]byte(in.Token)), p.Issuer, p.Subject).Scan(&deviceID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
d, err := readDevice(ctx, tx, deviceID, true)
if err != nil {
return nil, err
}
if !deviceAllowed(d) {
return nil, errAccess
}
c, err := readBLEClaim(ctx, tx, in.Token, d.ID)
if err != nil {
return nil, err
}
if err = bleClaimPermission(ctx, tx, c); err != nil {
return nil, err
}
status, err := bleResult(ctx, tx, d, c, s.now())
if err != nil {
return nil, err
}
return status, tx.Commit(ctx)
}
func bleResult(ctx context.Context, tx pgx.Tx, d Device, c bleClaim, now time.Time) (map[string]any, error) {
if c.BindingStatus == "COMMITTED" && c.AccountBindingID != "" {
var active bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_account_binding ab WHERE ab.id=$1::uuid AND ab.device_id=$2::uuid AND ab.user_id=$3::uuid AND ((ab.released_at IS NULL AND ab.generation=$4) OR EXISTS(SELECT 1 FROM device_binding b WHERE b.id=ab.assigned_binding_id AND b.unbound_at IS NULL AND b.generation=$4)))`, c.AccountBindingID, d.ID, c.UserID, d.Generation).Scan(&active)
if err != nil {
return nil, err
}
if active {
return map[string]any{"status": "success"}, nil
}
return map[string]any{"status": "failed", "message": "设备归属已变化,请刷新设备列表"}, nil
}
if c.BindingStatus == "COMMITTED" {
var active bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE id=$1::uuid AND device_id=$2::uuid AND unbound_at IS NULL AND generation=$3)`, c.BindingID, d.ID, c.Generation+1).Scan(&active)
if err != nil {
return nil, err
}
if active && d.Generation == c.Generation+1 {
return map[string]any{"status": "success"}, nil
}
return map[string]any{"status": "failed", "message": "设备归属已变化,请刷新设备列表"}, nil
}
if c.BindingStatus != "PENDING" || !c.Expires.After(now) || c.Generation != d.Generation {
return map[string]any{"status": "failed", "message": "绑定申请已过期或失效,请重新配网"}, nil
}
return map[string]any{"status": "pending"}, nil
}
// Only an authenticated device calls this after its physical confirmation. The
// request ID is the device's persisted confirmation event; no user-supplied owner
// or Wi-Fi password crosses this boundary.
func (s *Service) BindBLE(ctx context.Context, authenticated Device, requestID string, in BLEBindInput) (map[string]any, error) {
result, err := s.commitBLEBinding(ctx, authenticated, requestID, in)
if err != nil || s.options.LegacyDeviceRegistration {
return result, err
}
// The binding is durable before any provider HTTP call. A retry of the same
// token resumes this step without inserting another binding/registration.
return s.finishBLEVoice(ctx, authenticated, in)
}
func (s *Service) commitBLEBinding(ctx context.Context, authenticated Device, requestID string, in BLEBindInput) (map[string]any, error) {
if !validB64(in.Token, 32) || !uuidV4(requestID) {
return nil, fail(400, "VALIDATION_FAILED", "绑定请求不正确")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err = lockBLEControl(ctx, tx, authenticated.ID, ""); err != nil {
return nil, err
}
d, err := readDevice(ctx, tx, authenticated.ID, true)
if err != nil {
return nil, err
}
if !deviceAllowed(d) {
return nil, errAccess
}
var keyActive bool
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, authenticated.KeyVersion).Scan(&keyActive)
if err != nil {
return nil, err
}
if !keyActive {
return nil, errAuth
}
c, err := readBLEClaim(ctx, tx, in.Token, d.ID)
if err != nil {
return nil, err
}
if err = bleClaimPermission(ctx, tx, c); err != nil {
return nil, err
}
result, err := bleResult(ctx, tx, d, c, s.now())
if err != nil {
return nil, err
}
if result["status"] == "success" {
return result, tx.Commit(ctx)
}
if result["status"] != "pending" {
return nil, fail(409, "BIND_TOKEN_EXPIRED", "绑定申请已过期或归属发生变化")
}
if !s.Enabled() || s.options.PauseClaims {
return nil, fail(503, "ENROLLMENT_PAUSED", "设备绑定暂时暂停")
}
if c.BindingID != "" {
return s.commitBLETransfer(ctx, tx, d, c, requestID, in.Token)
}
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, fail(409, "ALREADY_BOUND", "设备已有有效绑定")
}
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", "设备还有另一笔待确认绑定")
}
if c.SubjectID == "" {
return s.commitBLEAccount(ctx, tx, d, c, requestID)
}
var owned bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND released_at IS NULL)`, d.ID).Scan(&owned); err != nil {
return nil, err
}
if owned {
return nil, fail(409, "ALREADY_BOUND", "设备已添加到账号,请从设备管理绑定老人")
}
result, err = s.commitBLESubject(ctx, tx, d, c, requestID, in.Token)
if err != nil {
return nil, err
}
return result, tx.Commit(ctx)
}
func (s *Service) commitBLESubject(ctx context.Context, tx pgx.Tx, d Device, c bleClaim, requestID, token string) (map[string]any, error) {
var err error
// Reuse the existing database guard and activation pipeline. BLE does not
// depend on a web handoff or a pre-network device boot registration.
setupID, bootID := uuid.NewString(), d.BootID
if bootID == "" {
bootID = uuid.NewString()
}
_, err = tx.Exec(ctx, `INSERT INTO device_setup_session(id,device_id,tenant_id,boot_id,local_setup_id,binding_generation,mode,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6,'ENROLL',$7)`, setupID, d.ID, d.TenantID, bootID, c.ID, d.Generation, c.Expires)
if err != nil {
return nil, err
}
_, 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)`, c.ID, d.ID, d.TenantID, setupID, c.UserID, c.HouseholdID, c.SubjectID, d.Generation, digest([]byte(token)), s.crypt.seal([]byte(token), "claim:"+c.ID), bindingDigest(d, c.UserID, c.HouseholdID, c.SubjectID, c.ID), c.Expires)
if err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `SELECT set_config('app.onboarding_claim',$1,true)`, c.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,'ble_physical_confirm') RETURNING id::text`, d.TenantID, d.ID, c.HouseholdID, c.SubjectID, c.UserID).Scan(&bindingID)
if err != nil {
return nil, err
}
if _, 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`, c.ID, requestID, bindingID); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='COMMITTED',binding_id=$2::uuid,completed_at=now() WHERE id=$1::uuid`, c.ID, bindingID); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, d.ID); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `UPDATE device_setup_session SET binding_generation=$2,closed_at=now(),close_reason='BLE_BOUND' WHERE id=$1::uuid`, setupID, d.Generation+1); err != nil {
return nil, err
}
if d.Lifecycle == "CLAIMABLE" {
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, d, d.Generation+1, requestID); err != nil {
return nil, err
}
_, 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)`, d.TenantID, c.SubjectID)
if err != nil {
return nil, err
}
if c.AccountBindingID == "" {
if err = s.auditEvent(ctx, tx, d, "device", d.PublicID, "device.binding.ble", requestID, map[string]any{"binding_generation": d.Generation + 1}); err != nil {
return nil, err
}
}
if _, err = s.state(ctx, tx, d); err != nil {
return nil, err
}
return map[string]any{"status": "success"}, nil
}
+131
View File
@@ -0,0 +1,131 @@
package onboarding
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
)
func TestBLEDeviceErrorsDescribeActualCondition(t *testing.T) {
cases := []struct {
name, sql, code, message string
status int
}{
{"not_released", `UPDATE device SET onboarding_released_at=NULL WHERE id=$1::uuid`, "DEVICE_NOT_RELEASED", "尚未放行", 409},
{"quarantined", `UPDATE device SET status='quarantined' WHERE id=$1::uuid`, "DEVICE_QUARANTINED", "已被隔离", 403},
{"revoked", `UPDATE device SET status='revoked' WHERE id=$1::uuid`, "DEVICE_REVOKED", "已被停用", 403},
{"lifecycle_revoked", `UPDATE device SET activation_state='REVOKED' WHERE id=$1::uuid`, "DEVICE_REVOKED", "已被停用", 403},
{"lost", `UPDATE device SET activation_state='LOST' WHERE id=$1::uuid`, "DEVICE_LOST", "标记为丢失", 403},
{"tenant_inactive", `UPDATE tenant SET status='suspended' WHERE id=(SELECT tenant_id FROM device WHERE id=$1::uuid)`, "TENANT_INACTIVE", "组织已停用", 403},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newFixture(t)
_, err := f.s.pool.Exec(f.ctx, tc.sql, f.d.ID)
must(t, err)
assertBLEAccessError(t, f, f.user, f.d.FactoryID, tc.status, tc.code, tc.message)
})
}
t.Run("malformed_and_unknown", func(t *testing.T) {
f := newFixture(t)
assertBLEAccessError(t, f, f.user, "bad-id", 400, "DEVICE_ID_INVALID", "编号格式不正确")
assertBLEAccessError(t, f, f.user, "KP01-"+strings.ToUpper(strings.ReplaceAll(uuid.NewString(), "-", "")[:12]), 404, "DEVICE_NOT_REGISTERED", "未找到该设备的注册记录")
})
t.Run("account_reasons", func(t *testing.T) {
f := newFixture(t)
assertBLEAccessError(t, f, identity.Principal{Issuer: f.user.Issuer, Subject: uuid.NewString()}, f.d.FactoryID, 403, "ACCOUNT_NOT_FOUND", "没有对应的平台账号")
assertBLEAccessError(t, f, f.p, f.d.FactoryID, 403, "CUSTOMER_REQUIRED", "切换到家庭账号")
foreign := newFixture(t)
assertBLEAccessError(t, f, foreign.user, f.d.FactoryID, 403, "DEVICE_TENANT_MISMATCH", "不属于当前账号所在组织")
for _, state := range []string{"disabled", "deleted"} {
_, err := f.s.pool.Exec(f.ctx, `UPDATE user_account SET status=$1 WHERE oidc_issuer=$2 AND oidc_subject=$3`, state, f.user.Issuer, f.user.Subject)
must(t, err)
assertBLEAccessError(t, f, f.user, f.d.FactoryID, 403, "ACCOUNT_"+strings.ToUpper(state), "当前账号已")
}
})
t.Run("legacy_device", func(t *testing.T) {
f := newFixture(t)
factoryID := "KP01-" + strings.ToUpper(strings.ReplaceAll(uuid.NewString(), "-", "")[:12])
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO device(tenant_id,serial_digest,model,credential_ref,factory_device_id) VALUES($1::uuid,$2,'legacy','test',$3)`, f.tenant, digest([]byte(factoryID)), factoryID)
must(t, err)
assertBLEAccessError(t, f, f.user, factoryID, 409, "DEVICE_ENROLLMENT_UNSUPPORTED", "尚未启用蓝牙实体确认绑定")
})
t.Run("other_account_and_stale_ownership", func(t *testing.T) {
f := newFixture(t)
p := accountOnlyUser(t, f)
token := accountToken(t, f, p)
_, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
assertBLEAccessError(t, f, f.user, f.d.FactoryID, 403, "DEVICE_OWNED_BY_OTHER_ACCOUNT", "已添加到其他账号")
state, err := f.s.BLEDeviceState(f.ctx, p, BLEDeviceInput{DeviceID: f.d.FactoryID})
must(t, err)
if state["bound"] != true {
t.Fatal(state)
}
_, err = f.s.CreateBLEToken(f.ctx, p, BLETokenInput{DeviceID: f.d.FactoryID})
assertBLEError(t, err, 409, "ALREADY_BOUND", "无需重复添加")
_, err = f.s.pool.Exec(f.ctx, `UPDATE device SET binding_generation=binding_generation+1 WHERE id=$1::uuid`, f.d.ID)
must(t, err)
assertBLEAccessError(t, f, p, f.d.FactoryID, 409, "DEVICE_BINDING_STALE", "归属记录与当前设备状态不一致")
})
t.Run("family_management_permission", func(t *testing.T) {
f := newFixture(t)
f.bound()
p := accountOnlyUser(t, f)
assertBLEAccessError(t, f, p, f.d.FactoryID, 403, "DEVICE_MANAGE_PERMISSION_REQUIRED", "没有该家庭的设备管理权限")
_, err := f.s.pool.Exec(f.ctx, `UPDATE household_member SET role='viewer' WHERE user_id=(SELECT id FROM user_account WHERE tenant_id=$1::uuid AND oidc_issuer=$2 AND oidc_subject=$3)`, f.tenant, f.user.Issuer, f.user.Subject)
must(t, err)
assertBLEAccessError(t, f, f.user, f.d.FactoryID, 403, "DEVICE_MANAGE_PERMISSION_REQUIRED", "没有该家庭的设备管理权限")
})
}
func assertBLEError(t *testing.T, err error, status int, code, message string) {
t.Helper()
var e *Error
if !errors.As(err, &e) || e.Status != status || e.Code != code || !strings.Contains(e.Message, message) {
t.Fatalf("want %d %s containing %q, got %#v", status, code, message, err)
}
}
func assertBLEAccessError(t *testing.T, f *fixture, p identity.Principal, factoryID string, status int, code, message string) {
t.Helper()
_, err := f.s.BLEDeviceState(f.ctx, p, BLEDeviceInput{DeviceID: factoryID})
assertBLEError(t, err, status, code, message)
_, err = f.s.CreateBLEToken(f.ctx, p, BLETokenInput{DeviceID: factoryID})
assertBLEError(t, err, status, code, message)
}
func TestMiniBLEDeviceErrorEnvelope(t *testing.T) {
f := newFixture(t)
session := randomToken(32)
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO onboarding_account_session(token_hash,csrf_hash,user_id,expires_at) SELECT $1,$2,id,now()+interval '1 hour' FROM user_account WHERE tenant_id=$3::uuid AND oidc_issuer=$4 AND oidc_subject=$5`, digest([]byte(session)), digest([]byte("test-csrf")), f.tenant, f.user.Issuer, f.user.Subject)
must(t, err)
_, err = f.s.pool.Exec(f.ctx, `UPDATE device SET onboarding_released_at=NULL WHERE id=$1::uuid`, f.d.ID)
must(t, err)
mux := http.NewServeMux()
f.s.RegisterRoutes(mux)
for _, endpoint := range []string{"ble-device-state", "ble-bind-tokens"} {
requestID := uuid.NewString()
r := httptest.NewRequest("POST", "https://platform.example/api/v1/mini/"+endpoint, bytes.NewReader(requestJSON(BLEDeviceInput{DeviceID: f.d.FactoryID})))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", "Mini customer."+session)
r.Header.Set("X-Request-ID", requestID)
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
var body map[string]any
must(t, json.Unmarshal(w.Body.Bytes(), &body))
if w.Code != 409 || body["code"] != "DEVICE_NOT_RELEASED" || body["message"] != "设备已登记但尚未放行,请联系管理员完成出厂放行" || body["request_id"] != requestID || body["ok"] != false {
t.Fatal(w.Code, body)
}
if _, leaked := body["data"]; leaked {
t.Fatal("error included device data", body)
}
}
}
+59
View File
@@ -0,0 +1,59 @@
package onboarding
import (
"context"
"crypto/hmac"
"fmt"
"time"
"data-terminal-cloud/internal/identity"
)
type BLESessionInput struct {
DeviceID string `json:"device_id"`
Model string `json:"model"`
KeyVersion int64 `json:"key_version"`
ClientNonce string `json:"client_nonce"`
ServerNonce string `json:"server_nonce"`
Proof string `json:"proof"`
}
func bleTranscript(in BLESessionInput) string {
return fmt.Sprintf("%s\n%s\n%d\n%s\n%s", in.DeviceID, in.Model, in.KeyVersion, in.ClientNonce, in.ServerNonce)
}
// A fresh link proves possession of the factory key. Only an eligible signed-in
// customer receives its short-lived SRP password over HTTPS, never the factory
// key or printed setup password. No account password goes to the device.
func (s *Service) BLESession(ctx context.Context, p identity.Principal, in BLESessionInput) (map[string]any, error) {
if !s.Enabled() || s.options.PauseClaims {
return nil, fail(503, "ENROLLMENT_PAUSED", "设备绑定暂时暂停")
}
if !factoryPattern.MatchString(in.DeviceID) || in.KeyVersion < 1 || !validB64(in.ClientNonce, 16) || !validB64(in.ServerNonce, 16) || !validDigest(in.Proof) {
return nil, errAccess
}
d, err := s.findFactoryDevice(ctx, in.DeviceID, in.KeyVersion)
if err != nil {
return nil, err
}
if !deviceAllowed(d) || d.Model != in.Model || !hmac.Equal([]byte(in.Proof), []byte(domainMAC(d.Key, "BLE-PROOF1\n"+bleTranscript(in)))) {
return nil, errAccess
}
if _, err = s.BLEDeviceState(ctx, p, BLEDeviceInput{DeviceID: in.DeviceID}); err != nil {
return nil, err
}
// A captured bootstrap cannot be redeemed by a different account after use.
key := "onboarding:ble-link:" + digest([]byte(in.Proof))
actor := digest([]byte(p.Issuer + "\n" + p.Subject))
if _, err = s.redis.SetNX(ctx, key, actor, 10*time.Minute).Result(); err != nil {
return nil, err
}
owner, err := s.redis.Get(ctx, key).Result()
if err != nil {
return nil, err
}
if owner != actor {
return nil, errAccess
}
return map[string]any{"device_id": d.FactoryID, "model": d.Model, "session_password": domainMAC(d.Key, "BLE-PASS1\n"+bleTranscript(in))}, nil
}
+123
View File
@@ -0,0 +1,123 @@
package onboarding
import (
"context"
"errors"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
type BLEDeviceInput struct {
DeviceID string `json:"device_id"`
}
// BLEDeviceState does not authenticate the radio. It authorizes the signed-in
// account and returns the authoritative cloud binding state, never owner data.
func (s *Service) BLEDeviceState(ctx context.Context, p identity.Principal, in BLEDeviceInput) (map[string]any, error) {
a, err := readBLEDeviceAccess(ctx, s.pool, p, in.DeviceID, false)
if err != nil {
return nil, err
}
return map[string]any{"device_id": in.DeviceID, "id": a.Device.PublicID, "model": a.Device.Model, "bound": a.Bound}, nil
}
type bleDeviceAccess struct {
Device Device
UserID string
Bound bool
}
// Keep state checks and token issuance consistent. Errors describe the failed
// condition, never the identity or profile of another device owner.
func readBLEDeviceAccess(ctx context.Context, q queryer, p identity.Principal, factoryID string, lock bool) (bleDeviceAccess, error) {
var a bleDeviceAccess
if !factoryPattern.MatchString(factoryID) {
return a, fail(400, "DEVICE_ID_INVALID", "设备编号格式不正确,请重新搜索并选择设备")
}
d := &a.Device
var tenantStatus, userStatus, accountKind string
var knownAccount, familyBound, accountBound, accountCurrent, ownAccount, manageFamily bool
suffix := ""
if lock {
suffix = " FOR UPDATE OF d"
}
err := q.QueryRow(ctx, `SELECT d.id::text,d.public_id::text,d.tenant_id::text,d.factory_device_id,d.model,
d.status,d.activation_state,d.enrollment_mode,d.binding_generation,d.onboarding_released_at IS NOT NULL,t.status,
COALESCE(ua.id::text,''),COALESCE(ua.status,''),COALESCE(ua.account_kind,''),
EXISTS(SELECT 1 FROM user_account WHERE oidc_issuer=$2 AND oidc_subject=$3),
EXISTS(SELECT 1 FROM device_binding WHERE device_id=d.id AND unbound_at IS NULL),
EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=d.id AND released_at IS NULL),
EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=d.id AND released_at IS NULL AND generation=d.binding_generation),
EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=d.id AND released_at IS NULL AND generation=d.binding_generation AND user_id=ua.id),
EXISTS(SELECT 1 FROM device_binding db JOIN household_member hm ON hm.household_id=db.household_id AND hm.tenant_id=db.tenant_id
JOIN household h ON h.id=hm.household_id AND h.status='active'
WHERE db.device_id=d.id AND db.unbound_at IS NULL AND hm.user_id=ua.id AND hm.status='active' AND hm.role IN ('owner','admin','editor'))
FROM device d JOIN tenant t ON t.id=d.tenant_id
LEFT JOIN user_account ua ON ua.tenant_id=d.tenant_id AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3
WHERE d.factory_device_id=$1`+suffix, factoryID, p.Issuer, p.Subject).Scan(
&d.ID, &d.PublicID, &d.TenantID, &d.FactoryID, &d.Model, &d.Status, &d.Lifecycle, &d.Mode, &d.Generation, &d.Released,
&tenantStatus, &a.UserID, &userStatus, &accountKind, &knownAccount, &familyBound, &accountBound, &accountCurrent, &ownAccount, &manageFamily)
if errors.Is(err, pgx.ErrNoRows) {
return a, fail(404, "DEVICE_NOT_REGISTERED", "平台未找到该设备的注册记录,请联系管理员登记设备")
}
if err != nil {
return a, err
}
if a.UserID == "" {
if knownAccount {
return a, fail(403, "DEVICE_TENANT_MISMATCH", "设备不属于当前账号所在组织,请切换到设备所属组织的账号")
}
return a, fail(403, "ACCOUNT_NOT_FOUND", "当前登录身份没有对应的平台账号,请重新登录或联系管理员")
}
if userStatus != "active" {
if userStatus == "deleted" {
return a, fail(403, "ACCOUNT_DELETED", "当前账号已删除,请使用其他有效账号登录")
}
return a, fail(403, "ACCOUNT_DISABLED", "当前账号已停用,请联系管理员恢复账号")
}
if accountKind != "customer" {
return a, fail(403, "CUSTOMER_REQUIRED", "当前登录的是管理账号,请切换到家庭账号添加设备")
}
if tenantStatus != "active" {
return a, fail(403, "TENANT_INACTIVE", "设备所属组织已停用,请联系平台管理员")
}
if d.Status == "revoked" || d.Lifecycle == "REVOKED" {
return a, fail(403, "DEVICE_REVOKED", "设备已被停用,无法添加或配网,请联系管理员")
}
if d.Lifecycle == "LOST" {
return a, fail(403, "DEVICE_LOST", "设备已被标记为丢失,请先联系管理员解除丢失状态")
}
if d.Status == "quarantined" {
return a, fail(403, "DEVICE_QUARANTINED", "设备已被隔离,暂时禁止添加或配网,请联系管理员检查设备")
}
if d.Mode != "physical_confirm" {
return a, fail(409, "DEVICE_ENROLLMENT_UNSUPPORTED", "该设备尚未启用蓝牙实体确认绑定,请联系管理员完成设备接入配置")
}
if !d.Released {
return a, fail(409, "DEVICE_NOT_RELEASED", "设备已登记但尚未放行,请联系管理员完成出厂放行")
}
if accountBound && !accountCurrent && !manageFamily {
return a, fail(409, "DEVICE_BINDING_STALE", "设备归属记录与当前设备状态不一致,请联系管理员处理")
}
if accountBound && !ownAccount && !manageFamily {
return a, fail(403, "DEVICE_OWNED_BY_OTHER_ACCOUNT", "设备已添加到其他账号,请使用原账号操作;如需更换账号,请先由原账号移除设备")
}
if familyBound && !manageFamily && !ownAccount {
return a, fail(403, "DEVICE_MANAGE_PERMISSION_REQUIRED", "设备已绑定家庭,当前账号没有该家庭的设备管理权限,请联系家庭管理员授权")
}
a.Bound = familyBound || accountCurrent
return a, nil
}
// DeviceBLEState is read-only: an empty BLE bind_token must never create a
// binding or bypass a cloud reset. Device authentication remains HMAC + HTTPS.
func (s *Service) DeviceBLEState(ctx context.Context, d Device) (map[string]any, error) {
if !deviceAllowed(d) {
return nil, errAccess
}
var bound bool
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)
OR EXISTS(SELECT 1 FROM device_account_binding ab JOIN device d ON d.id=ab.device_id WHERE ab.device_id=$1::uuid AND ab.released_at IS NULL AND ab.generation=d.binding_generation)`, d.ID).Scan(&bound)
return map[string]any{"bound": bound}, err
}
+60
View File
@@ -0,0 +1,60 @@
package onboarding
import (
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"testing"
)
func TestBLEPlaintextBindingState(t *testing.T) {
f := newFixture(t)
in := BLEDeviceInput{DeviceID: f.d.FactoryID}
state, err := f.s.BLEDeviceState(f.ctx, f.user, in)
must(t, err)
if state["bound"] != false || state["id"] != f.d.PublicID {
t.Fatal(state)
}
device, err := f.s.DeviceBLEState(f.ctx, f.d)
must(t, err)
if device["bound"] != false {
t.Fatal(device)
}
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: ""})
if err == nil {
t.Fatal("empty token must not create a binding")
}
_, err = f.s.BLEDeviceState(f.ctx, identity.Principal{Issuer: f.user.Issuer, Subject: uuid.NewString()}, in)
if err == nil {
t.Fatal("unrelated account read binding state")
}
token := f.bleToken()
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
f.refresh()
generation := f.d.Generation
state, err = f.s.BLEDeviceState(f.ctx, f.user, in)
must(t, err)
if state["bound"] != true {
t.Fatal(state)
}
for i := 0; i < 3; i++ {
device, err = f.s.DeviceBLEState(f.ctx, f.d)
must(t, err)
if device["bound"] != true {
t.Fatal(device)
}
}
f.refresh()
if f.d.Generation != generation {
t.Fatal("WiFi checks modified ownership")
}
_, 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)
f.web.Hash = "" // Unbinding intentionally expires the fixture's old setup session.
f.refresh()
device, err = f.s.DeviceBLEState(f.ctx, f.d)
must(t, err)
if device["bound"] != false {
t.Fatal("stale bound state after unbind", device)
}
}
+119
View File
@@ -0,0 +1,119 @@
package onboarding
import (
"strings"
"sync"
"testing"
"time"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
)
func (f *fixture) bleToken() string {
f.t.Helper()
r, err := f.s.CreateBLEToken(f.ctx, f.user, BLETokenInput{DeviceID: f.d.FactoryID, ElderID: f.elder})
must(f.t, err)
return r["bind_token"].(string)
}
func TestBLEBindingLifecycle(t *testing.T) {
f := newFixture(t)
first := f.bleToken()
token := f.bleToken()
if first == token || !validB64(token, 32) {
t.Fatal("token must be fresh and contain 32 random bytes")
}
r, err := f.s.BLEStatus(f.ctx, f.user, BLEBindInput{Token: first})
must(t, err)
if r["status"] != "failed" {
t.Fatal("replaced token remained valid")
}
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: first})
if err == nil {
t.Fatal("replaced token accepted")
}
r, err = f.s.BLEStatus(f.ctx, f.user, BLEBindInput{Token: token})
must(t, err)
if r["status"] != "pending" {
t.Fatal(r)
}
_, err = f.s.BLEStatus(f.ctx, identity.Principal{Issuer: f.user.Issuer, Subject: uuid.NewString()}, BLEBindInput{Token: token})
if err == nil {
t.Fatal("another account read the binding result")
}
r, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
if r["status"] != "success" {
t.Fatal(r)
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_ble_binding SET expires_at=now()-interval '1 minute' WHERE token_hash=$1`, digest([]byte(token)))
must(t, err)
// A lost response must be recoverable even after the token's original TTL.
r, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
if r["status"] != "success" {
t.Fatal(r)
}
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 != 1 {
t.Fatalf("duplicate bindings: %d", count)
}
f.refresh()
if f.d.Generation != 1 {
t.Fatalf("generation %d", f.d.Generation)
}
_, err = f.s.CreateBLEToken(f.ctx, f.user, BLETokenInput{DeviceID: f.d.FactoryID, ElderID: f.elder})
if err == nil {
t.Fatal("already-bound device admitted a new claim")
}
}
func TestBLEBindingRejectsExpiredAndWrongDevice(t *testing.T) {
f, other := newFixture(t), newFixture(t)
token := f.bleToken()
_, err := f.s.BindBLE(f.ctx, other.d, uuid.NewString(), BLEBindInput{Token: token})
if err == nil {
t.Fatal("token accepted by another device")
}
_, err = f.s.CreateBLEToken(f.ctx, f.user, BLETokenInput{DeviceID: f.d.FactoryID, ElderID: other.elder})
if err == nil {
t.Fatal("foreign subject accepted")
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_ble_binding SET expires_at=$2 WHERE token_hash=$1`, digest([]byte(token)), time.Now().Add(-time.Minute))
must(t, err)
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
if err == nil {
t.Fatal("expired token bound device")
}
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: strings.Repeat("x", 44)})
if err == nil {
t.Fatal("malformed token accepted")
}
}
func TestBLEConcurrentRetryIsIdempotent(t *testing.T) {
f := newFixture(t)
token := f.bleToken()
var wg sync.WaitGroup
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
errs <- err
}()
}
wg.Wait()
close(errs)
for err := range errs {
must(t, err)
}
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 != 1 {
t.Fatalf("concurrent bind created %d bindings", count)
}
}
+93
View File
@@ -0,0 +1,93 @@
package onboarding
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
)
// Join the direct-call transaction fence before taking the same twin/device
// lock order as commands and OTA. A new prepare cannot race the final idle check.
func lockBLEControl(ctx context.Context, tx pgx.Tx, deviceID, factoryID string) error {
if deviceID == "" {
err := tx.QueryRow(ctx, `SELECT id::text FROM device WHERE factory_device_id=$1`, factoryID).Scan(&deviceID)
if errors.Is(err, pgx.ErrNoRows) {
return nil
} // Normal access validation supplies the precise error.
if err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,11))`, deviceID); err != nil {
return err
}
_, err := tx.Exec(ctx, `SELECT device_id FROM device_twin WHERE device_id=$1::uuid FOR UPDATE`, deviceID)
return err
}
func (s *Service) checkBLETransfer(ctx context.Context, tx pgx.Tx, d Device, target claimRecord, sourceID string) error {
source := claimRecord{TenantID: d.TenantID, UserID: target.UserID}
err := tx.QueryRow(ctx, `SELECT household_id::text,subject_id::text FROM device_binding WHERE id=$1::uuid AND device_id=$2::uuid AND tenant_id=$3::uuid AND unbound_at IS NULL AND generation=$4 FOR UPDATE`, sourceID, d.ID, d.TenantID, d.Generation).Scan(&source.HouseholdID, &source.SubjectID)
if errors.Is(err, pgx.ErrNoRows) {
return fail(409, "BINDING_CHANGED", "设备归属已变化,请刷新后重新选择")
}
if err != nil {
return err
}
if err = claimPermission(ctx, tx, source); err != nil {
return err
}
if source.SubjectID == target.SubjectID {
return fail(400, "ELDER_UNCHANGED", "请选择另一位使用老人")
}
if err = s.expireClaims(ctx, tx, d.ID); err != nil {
return err
}
var busy bool
err = tx.QueryRow(ctx, `SELECT
EXISTS(SELECT 1 FROM call_session WHERE device_id=$1::uuid AND call_state IN ('PREPARING','STARTING','TALKING','STOPPING')) OR
EXISTS(SELECT 1 FROM conversation WHERE device_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')) OR
EXISTS(SELECT 1 FROM device_command WHERE device_id=$1::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now()) OR
EXISTS(SELECT 1 FROM firmware_assignment WHERE device_id=$1::uuid AND status IN ('PENDING','ASSIGNED','COMMAND_QUEUED','DOWNLOADING','INSTALLING')) OR
EXISTS(SELECT 1 FROM fleet_ota WHERE device_id=$1::uuid AND status IN ('pending','accepted')) OR
EXISTS(SELECT 1 FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING') OR
EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND released_at IS NULL)`, d.ID).Scan(&busy)
if err != nil {
return err
}
if busy {
return fail(409, "DEVICE_BUSY", "设备有通话、命令、升级或待确认操作,请完成后重试")
}
return nil
}
// Only commitBLEBinding, after DEVREQ1 device authentication, can reach this.
// The old assignment stays active while a token waits for the physical tap.
func (s *Service) commitBLETransfer(ctx context.Context, tx pgx.Tx, d Device, c bleClaim, requestID, token string) (map[string]any, error) {
if err := s.checkBLETransfer(ctx, tx, d, c.claimRecord, c.BindingID); err != nil {
return nil, err
}
sourceID := c.BindingID
if _, err := tx.Exec(ctx, `UPDATE device_binding SET unbound_at=now(),unbound_by=$2::uuid,reason='ble_physical_transfer' WHERE id=$1::uuid`, sourceID, c.UserID); err != nil {
return nil, err
}
// Closing the old binding advances the generation. The new claim must be
// fenced against that value, and its receipt must survive response retries.
d, err := readDevice(ctx, tx, d.ID, true)
if err != nil {
return nil, err
}
c.Generation = d.Generation
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET expected_generation=$2 WHERE id=$1::uuid`, c.ID, d.Generation); err != nil {
return nil, err
}
result, err := s.commitBLESubject(ctx, tx, d, c, requestID, token)
if err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "device", d.PublicID, "device.binding.ble_transfer", requestID, map[string]any{"previous_binding_id": sourceID, "binding_generation": d.Generation + 1}); err != nil {
return nil, err
}
return result, tx.Commit(ctx)
}
+300
View File
@@ -0,0 +1,300 @@
package onboarding
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/google/uuid"
)
func transferTarget(t *testing.T, f *fixture) string {
t.Helper()
id := uuid.NewString()
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO subject_profile(public_id,tenant_id,household_id,preferred_name,status) SELECT $1,tenant_id,household_id,'另一位老人','active' FROM subject_profile WHERE public_id=$2`, id, f.elder)
must(t, err)
return id
}
func transferInput(t *testing.T, f *fixture, target string) BLETokenInput {
t.Helper()
var binding string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT public_id::text FROM device_binding WHERE device_id=$1 AND unbound_at IS NULL`, f.d.ID).Scan(&binding))
return BLETokenInput{DeviceID: f.d.FactoryID, ElderID: target, ExpectedBindingID: binding}
}
func transferUnchanged(t *testing.T, f *fixture, binding string, generation int64) {
t.Helper()
var current string
var gen int64
must(t, f.s.pool.QueryRow(f.ctx, `SELECT b.public_id::text,d.binding_generation FROM device d JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL WHERE d.id=$1`, f.d.ID).Scan(&current, &gen))
if current != binding || gen != generation {
t.Fatalf("original binding changed: %s / %d", current, gen)
}
}
func TestBLETransferRequiresPhysicalDeviceAndReplays(t *testing.T) {
f := newFixture(t)
f.bound()
f.web.Hash = ""
target := transferTarget(t, f)
in := transferInput(t, f, target)
session := webBLESession(t, f, f.user)
status, result := webBLERequest(t, f.s, "ble-bind-tokens", session, webBLECSRF, "https://platform.example", in)
if status != 200 {
t.Fatalf("issue token %d %v", status, result)
}
token := result["bind_token"].(string)
transferUnchanged(t, f, in.ExpectedBindingID, 1)
r, err := f.s.BLEStatus(f.ctx, f.user, BLEBindInput{Token: token})
must(t, err)
if r["status"] != "pending" {
t.Fatal(r)
}
// Browser authentication is not enough to commit; the device signature is required.
unsigned := httptest.NewRequest("POST", "https://platform.example/api/v1/device/ble-bind", bytes.NewReader(requestJSON(BLEBindInput{Token: token})))
unsigned.Header.Set("X-Request-ID", uuid.NewString())
unsigned.Header.Set("Content-Type", "application/json")
unsigned.AddCookie(&http.Cookie{Name: f.s.cookieName(true), Value: session})
mux := http.NewServeMux()
f.s.RegisterRoutes(mux)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, unsigned)
if recorder.Code != 401 {
t.Fatalf("unsigned confirmation: %d", recorder.Code)
}
transferUnchanged(t, f, in.ExpectedBindingID, 1)
status, result = f.deviceCall("ble-bind", BLEBindInput{Token: token}, "", true)
if status != 200 {
t.Fatalf("device confirmation %d %v", status, result)
}
var elder string
var generation int64
must(t, f.s.pool.QueryRow(f.ctx, `SELECT sp.public_id::text,d.binding_generation FROM device d JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL JOIN subject_profile sp ON sp.id=b.subject_id WHERE d.id=$1`, f.d.ID).Scan(&elder, &generation))
if elder != target || generation != 3 {
t.Fatalf("target %s generation %d", elder, generation)
}
// Concurrent retries after a lost response cannot create a second transfer.
var wg sync.WaitGroup
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
errs <- err
}()
}
wg.Wait()
close(errs)
for err := range errs {
must(t, err)
}
var total int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM device_binding WHERE device_id=$1`, f.d.ID).Scan(&total))
if total != 2 {
t.Fatalf("binding rows %d", total)
}
r, err = f.s.BLEStatus(f.ctx, f.user, BLEBindInput{Token: token})
must(t, err)
if r["status"] != "success" {
t.Fatal(r)
}
}
func TestBLETransferRejectsInvalidTargetsAndStaleSource(t *testing.T) {
f, other := newFixture(t), newFixture(t)
f.bound()
in := transferInput(t, f, transferTarget(t, f))
for _, invalid := range []BLETokenInput{
{DeviceID: f.d.FactoryID, ElderID: in.ElderID},
{DeviceID: f.d.FactoryID, ElderID: in.ElderID, ExpectedBindingID: uuid.NewString()},
{DeviceID: f.d.FactoryID, ElderID: f.elder, ExpectedBindingID: in.ExpectedBindingID},
{DeviceID: f.d.FactoryID, ElderID: other.elder, ExpectedBindingID: in.ExpectedBindingID},
{DeviceID: f.d.FactoryID, ExpectedBindingID: in.ExpectedBindingID},
} {
if _, err := f.s.CreateBLEToken(f.ctx, f.user, invalid); err == nil {
t.Fatalf("invalid transfer accepted: %+v", invalid)
}
transferUnchanged(t, f, in.ExpectedBindingID, 1)
}
if _, err := f.s.CreateBLEToken(f.ctx, other.user, in); err == nil {
t.Fatal("foreign user got token")
}
r, err := f.s.CreateBLEToken(f.ctx, f.user, in)
must(t, err)
token := r["bind_token"].(string)
if _, err = f.s.BindBLE(f.ctx, other.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil {
t.Fatal("wrong device accepted")
}
// An intervening release invalidates the pending transfer permanently.
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1 AND unbound_at IS NULL`, f.d.ID)
must(t, err)
if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil {
t.Fatal("stale source accepted")
}
}
func TestBLETransferRechecksPermissionAndExpiry(t *testing.T) {
for _, change := range []string{"role", "target", "expiry"} {
t.Run(change, func(t *testing.T) {
f := newFixture(t)
f.bound()
in := transferInput(t, f, transferTarget(t, f))
r, err := f.s.CreateBLEToken(f.ctx, f.user, in)
must(t, err)
token := r["bind_token"].(string)
switch change {
case "role":
_, err = f.s.pool.Exec(f.ctx, `UPDATE household_member SET role='viewer' WHERE user_id=$1`, f.web.UserID)
case "target":
_, err = f.s.pool.Exec(f.ctx, `UPDATE subject_profile SET status='suspended' WHERE public_id=$1`, in.ElderID)
case "expiry":
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_ble_binding SET expires_at=now()-interval '1 minute' WHERE token_hash=$1`, digest([]byte(token)))
}
must(t, err)
if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil {
t.Fatal("changed authorization accepted")
}
transferUnchanged(t, f, in.ExpectedBindingID, 1)
})
}
}
func TestBLETransferRechecksDirectCallAndOTABeforeCommit(t *testing.T) {
f := newFixture(t)
f.bound()
in := transferInput(t, f, transferTarget(t, f))
r, err := f.s.CreateBLEToken(f.ctx, f.user, in)
must(t, err)
token := r["bind_token"].(string)
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO call_session(device_id,tenant_id,boot_id,hook_event_id,call_generation,binding_generation) VALUES($1,$2,$3,'transfer-guard',1,1)`, f.d.ID, f.tenant, uuid.NewString())
must(t, err)
for _, phase := range []string{"PREPARING", "STARTING", "TALKING", "STOPPING"} {
_, err = f.s.pool.Exec(f.ctx, `UPDATE call_session SET call_state=$2 WHERE device_id=$1`, f.d.ID, phase)
must(t, err)
if _, err = f.s.CreateBLEToken(f.ctx, f.user, in); err == nil {
t.Fatalf("issued while %s", phase)
}
if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil {
t.Fatalf("committed while %s", phase)
}
transferUnchanged(t, f, in.ExpectedBindingID, 1)
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE call_session SET call_state='ENDED' WHERE device_id=$1`, f.d.ID)
must(t, err)
var release string
must(t, f.s.pool.QueryRow(f.ctx, `INSERT INTO fleet_firmware(tenant_id,model,version,size,sha256,object_ref,status) VALUES($1,$2,'transfer-guard',32,$3,$4,'READY') RETURNING id::text`, f.tenant, f.d.Model, digest([]byte("test")), "test://"+uuid.NewString()).Scan(&release))
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO fleet_ota(device_id,release_id,request_id) VALUES($1,$2,$3)`, f.d.ID, release, uuid.NewString())
must(t, err)
for _, phase := range []string{"pending", "accepted"} {
_, err = f.s.pool.Exec(f.ctx, `UPDATE fleet_ota SET status=$2 WHERE device_id=$1`, f.d.ID, phase)
must(t, err)
if _, err = f.s.CreateBLEToken(f.ctx, f.user, in); err == nil {
t.Fatalf("issued while OTA %s", phase)
}
if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil {
t.Fatalf("committed while OTA %s", phase)
}
transferUnchanged(t, f, in.ExpectedBindingID, 1)
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE fleet_ota SET status='failed' WHERE device_id=$1`, f.d.ID)
must(t, err)
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
}
func TestBLETransferInsertionFailureRollsBackOriginalBinding(t *testing.T) {
f := newFixture(t)
f.bound()
in := transferInput(t, f, transferTarget(t, f))
r, err := f.s.CreateBLEToken(f.ctx, f.user, in)
must(t, err)
token := r["bind_token"].(string)
var targetID string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT id::text FROM subject_profile WHERE public_id=$1`, in.ElderID).Scan(&targetID))
// Fault only this generated target in the disposable integration database.
constraint := "test_transfer_" + uuid.NewString()[:8]
_, err = f.s.pool.Exec(f.ctx, fmt.Sprintf(`ALTER TABLE device_binding ADD CONSTRAINT %s CHECK(subject_id<>'%s'::uuid)`, constraint, targetID))
must(t, err)
defer f.s.pool.Exec(f.ctx, `ALTER TABLE device_binding DROP CONSTRAINT `+constraint)
if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}); err == nil {
t.Fatal("expected injected insertion failure")
}
transferUnchanged(t, f, in.ExpectedBindingID, 1)
_, err = f.s.pool.Exec(f.ctx, `ALTER TABLE device_binding DROP CONSTRAINT `+constraint)
must(t, err)
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
}
func TestBLETransferReusesVoiceIdentityAndFencesOldReceipt(t *testing.T) {
f := serverFixture(t)
firstToken := f.bleToken()
first, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: firstToken})
must(t, err)
f.web.Hash = ""
in := transferInput(t, f, transferTarget(t, f))
r, err := f.s.CreateBLEToken(f.ctx, f.user, in)
must(t, err)
second, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: r["bind_token"].(string)})
must(t, err)
if first["device_secret"] != second["device_secret"] || first["device_name"] != second["device_name"] || f.registrar.calls.Load() != 1 {
t.Fatal("transfer changed provider identity or registered twice")
}
if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: firstToken}); err == nil {
t.Fatal("old elder receipt remains usable")
}
}
func TestBLETransferRechecksSourcePermissionIndependently(t *testing.T) {
f := newFixture(t)
f.bound()
var house, target string
must(t, f.s.pool.QueryRow(f.ctx, `INSERT INTO household(tenant_id,name,owner_user_id) VALUES($1,'目标家庭',$2) RETURNING id::text`, f.tenant, f.web.UserID).Scan(&house))
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO household_member(tenant_id,household_id,user_id,role,status) VALUES($1,$2,$3,'admin','active')`, f.tenant, house, f.web.UserID)
must(t, err)
must(t, f.s.pool.QueryRow(f.ctx, `INSERT INTO subject_profile(tenant_id,household_id,preferred_name,status) VALUES($1,$2,'目标老人','active') RETURNING public_id::text`, f.tenant, house).Scan(&target))
in := transferInput(t, f, target)
r, err := f.s.CreateBLEToken(f.ctx, f.user, in)
must(t, err)
_, err = f.s.pool.Exec(f.ctx, `UPDATE household_member SET role='viewer' WHERE user_id=$1 AND household_id<>$2`, f.web.UserID, house)
must(t, err)
if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: r["bind_token"].(string)}); err == nil {
t.Fatal("target permission bypassed revoked source permission")
}
transferUnchanged(t, f, in.ExpectedBindingID, 1)
}
func TestBLETransferSerializesWithInFlightCallPreparation(t *testing.T) {
f := newFixture(t)
f.bound()
in := transferInput(t, f, transferTarget(t, f))
r, err := f.s.CreateBLEToken(f.ctx, f.user, in)
must(t, err)
tx, err := f.s.pool.Begin(f.ctx)
must(t, err)
defer tx.Rollback(f.ctx)
// This is the transaction fence used by callmemory.Service.DeviceRequest.
_, err = tx.Exec(f.ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,11))`, f.d.ID)
must(t, err)
_, err = tx.Exec(f.ctx, `INSERT INTO call_session(device_id,tenant_id,boot_id,hook_event_id,call_generation,binding_generation) VALUES($1,$2,$3,'transfer-race',1,1)`, f.d.ID, f.tenant, uuid.NewString())
must(t, err)
ctx, cancel := context.WithTimeout(f.ctx, 150*time.Millisecond)
defer cancel()
_, err = f.s.BindBLE(ctx, f.d, uuid.NewString(), BLEBindInput{Token: r["bind_token"].(string)})
if err == nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) {
t.Fatalf("transfer did not wait for call preparation: %v", err)
}
must(t, tx.Commit(f.ctx))
if _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: r["bind_token"].(string)}); err == nil {
t.Fatal("newly committed call was ignored")
}
transferUnchanged(t, f, in.ExpectedBindingID, 1)
}
+100
View File
@@ -0,0 +1,100 @@
package onboarding
import (
"context"
"encoding/json"
"errors"
"time"
"data-terminal-cloud/internal/outbox"
)
// Wait in this HTTP request, not in the binding transaction. The outbox worker
// uses the same durable registration fence and may finish the work concurrently.
func (s *Service) finishBLEVoice(ctx context.Context, d Device, in BLEBindInput) (out map[string]any, err error) {
ctx, cancel := context.WithTimeout(ctx, 35*time.Second)
defer cancel()
defer func() {
if errors.Is(err, context.DeadlineExceeded) {
err = fail(504, "VOICE_REGISTRATION_TIMEOUT", "获取火山配置超时,绑定已保存,请使用原绑定令牌重试")
}
}()
started := false
for {
fields, event, e := s.bleVoiceSnapshot(ctx, d, in)
if e != nil {
return nil, e
}
switch fields["voice_status"] {
case "ready":
v, ok := fields["voice"].(DeviceVoiceCredential)
if !ok || v.InstanceID == "" || v.ProductKey == "" || v.DeviceName == "" || !validVoiceSecret(v.DeviceSecret) || !validRTCAppID(v.RTCAppID) {
return nil, fail(502, "VOICE_CONFIG_INVALID", "火山配置不完整,绑定已保存,请联系平台处理")
}
return map[string]any{"status": "success", "instance_id": v.InstanceID, "product_key": v.ProductKey, "device_name": v.DeviceName, "device_secret": v.DeviceSecret, "RTCAppID": v.RTCAppID}, nil
case "paused":
return nil, fail(503, "VOICE_REGISTRATION_PAUSED", "语音服务暂不可用,绑定已保存,请稍后使用原绑定令牌重试")
case "migration_required":
return nil, fail(409, "VOICE_MIGRATION_REQUIRED", "绑定已保存,旧设备需要补交原火山凭据,请联系平台处理")
case "reconcile_required":
return nil, fail(502, "VOICE_REGISTRATION_FAILED", "绑定已保存,火山注册结果未确认,请联系平台核对")
}
if !started {
started = true
if e = s.processServerActivation(ctx, event); e != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fail(502, "VOICE_REGISTRATION_FAILED", "绑定已保存,获取火山配置失败,请稍后重试或联系平台处理")
}
continue
}
timer := time.NewTimer(250 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
}
func (s *Service) bleVoiceSnapshot(ctx context.Context, authenticated Device, in BLEBindInput) (map[string]any, outbox.Event, error) {
var event outbox.Event
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, event, err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, authenticated.ID, true)
if err != nil {
return nil, event, err
}
c, err := readBLEClaim(ctx, tx, in.Token, d.ID)
if err != nil {
return nil, event, err
}
result, err := bleResult(ctx, tx, d, c, s.now())
if err != nil {
return nil, event, err
}
if !deviceAllowed(d) || result["status"] != "success" {
return nil, event, fail(409, "BINDING_CHANGED", "设备已解绑或归属发生变化,请重新绑定")
}
if err = bleClaimPermission(ctx, tx, c); err != nil {
return nil, event, err
}
fields, err := s.voiceFields(ctx, tx, d)
if err != nil {
return nil, event, err
}
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return nil, event, err
}
event.Payload, err = json.Marshal(map[string]any{"device_id": d.ID, "activation_id": j.ID, "binding_generation": d.Generation})
if err != nil {
return nil, event, err
}
return fields, event, tx.Commit(ctx)
}
+84
View File
@@ -0,0 +1,84 @@
package onboarding
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
)
const webBLECSRF = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
func webBLESession(t *testing.T, f *fixture, p identity.Principal) string {
t.Helper()
session := randomToken(32)
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO onboarding_account_session(token_hash,csrf_hash,user_id,expires_at) SELECT $1,$2,id,now()+interval '1 hour' FROM user_account WHERE oidc_issuer=$3 AND oidc_subject=$4`, digest([]byte(session)), digest([]byte(webBLECSRF)), p.Issuer, p.Subject)
must(t, err)
return session
}
func webBLERequest(t *testing.T, s *Service, endpoint, session, csrf, origin string, in any) (int, map[string]any) {
t.Helper()
raw, _ := json.Marshal(in)
r := httptest.NewRequest("POST", "https://platform.example/api/v1/web/"+endpoint, bytes.NewReader(raw))
r.Header.Set("Origin", origin)
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Request-ID", uuid.NewString())
r.Header.Set("X-CSRF-Token", csrf)
if session != "" {
r.AddCookie(&http.Cookie{Name: s.cookieName(true), Value: session})
}
w := httptest.NewRecorder()
mux := http.NewServeMux()
s.RegisterRoutes(mux)
mux.ServeHTTP(w, r)
var out map[string]any
must(t, json.Unmarshal(w.Body.Bytes(), &out))
data, _ := out["data"].(map[string]any)
return w.Code, data
}
func TestWebBLEStateRequiresOwnerOriginAndCSRF(t *testing.T) {
f := serverFixture(t)
f.web.Hash = ""
p := accountOnlyUser(t, f)
session := webBLESession(t, f, p)
in := BLEDeviceInput{DeviceID: f.d.FactoryID}
for _, endpoint := range []string{"ble-device-state", "device-subject", "ble-bind-tokens"} {
for _, auth := range [][3]string{{"", webBLECSRF, "https://platform.example"}, {session, "wrong", "https://platform.example"}, {session, webBLECSRF, "https://evil.example"}} {
status, _ := webBLERequest(t, f.s, endpoint, auth[0], auth[1], auth[2], in)
if status != 401 && status != 403 {
t.Fatalf("%s allowed invalid auth: %d", endpoint, status)
}
}
}
status, out := webBLERequest(t, f.s, "ble-bind-tokens", session, webBLECSRF, "https://platform.example", BLETokenInput{DeviceID: f.d.FactoryID})
if status != 200 {
t.Fatal("account-first token", status, out)
}
_, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: out["bind_token"].(string)})
must(t, err)
f.refresh()
generation := f.d.Generation
status, out = webBLERequest(t, f.s, "ble-device-state", session, webBLECSRF, "https://platform.example", in)
if status != 200 || out["bound"] != true || out["id"] != f.d.PublicID {
t.Fatal(status, out)
}
other := webBLESession(t, f, accountOnlyUser(t, f))
status, _ = webBLERequest(t, f.s, "ble-device-state", other, webBLECSRF, "https://platform.example", in)
if status != 403 {
t.Fatal("other account can reconfigure", status)
}
status, _ = webBLERequest(t, f.s, "ble-bind-tokens", session, webBLECSRF, "https://platform.example", BLETokenInput{DeviceID: f.d.FactoryID})
if status != 409 {
t.Fatal("bound device received another claim", status)
}
f.refresh()
if f.d.Generation != generation {
t.Fatal("Wi-Fi eligibility changed ownership")
}
}
+26
View File
@@ -0,0 +1,26 @@
package onboarding
import (
"data-terminal-cloud/internal/deviceauth"
"mime"
"net/http"
"time"
)
// AuthenticateCallRequest verifies ONBOARDING-1.1 identity without applying the
// legacy voice route's admission rules. CALL-MEMORY-1.1 performs its own scoped
// admission and allows old-session end receipts without issuing new authority.
func (s *Service) AuthenticateCallRequest(r *http.Request) (deviceauth.Identity, []byte, []byte, error) {
mediaType, _, mediaErr := mime.ParseMediaType(r.Header.Get("Content-Type"))
if !s.secure(r) || r.URL.RawQuery != "" || mediaErr != nil || mediaType != "application/json" {
return deviceauth.Identity{}, nil, nil, errAuth
}
d, raw, e := s.authenticate(r)
if e != nil {
return deviceauth.Identity{}, nil, nil, e
}
if e = s.limit(r.Context(), "call-device:"+d.ID, 300, time.Minute); e != nil {
return deviceauth.Identity{}, nil, nil, e
}
return deviceauth.Identity{ID: d.ID, PublicID: d.PublicID, TenantID: d.TenantID, Status: d.Status, ActivationState: d.Lifecycle, EnrollmentMode: d.Mode, BindingGeneration: d.Generation, CredentialRef: "onboarding://" + d.FactoryID}, d.Key, raw, nil
}
@@ -0,0 +1,80 @@
package onboarding
import (
"context"
"testing"
)
func TestSaaSCaptchaDisabledScope(t *testing.T) {
s := &Service{options: Options{SaaSCaptchaDisabled: true}}
if !s.saasCaptchaDisabled(context.Background()) {
t.Fatal("web not disabled")
}
mini := context.WithValue(context.Background(), captchaRequestKey{}, captchaRequestInfo{client: "mini_program"})
if s.saasCaptchaDisabled(mini) {
t.Fatal("mini affected")
}
s.options.SaaSCaptchaDisabled = false
if s.saasCaptchaDisabled(context.Background()) {
t.Fatal("default bypass")
}
}
func TestSaaSCaptchaDisabledSMSAndGuards(t *testing.T) {
f := newLoginFixture(t)
f.s.options.SaaSCaptchaDisabled = true
setCaptchaTestPolicy(t, f.fixture, "slide", "image")
w, ch := f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
if w.Code != 200 || ch["mode"] != "none" || ch["image"] != nil {
t.Fatal(w.Body.String())
}
in := SMSSendInput{Phone: f.phone, Purpose: "LOGIN"}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, "wrong", "")
if w.Code != 403 || f.sender.calls != 0 {
t.Fatal("CSRF bypass")
}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, nil, f.csrf, "")
if w.Code != 401 || f.sender.calls != 0 {
t.Fatal("session bypass")
}
w, sms := f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, "")
if w.Code != 200 || f.sender.calls != 1 {
t.Fatal(w.Body.String())
}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, "")
if w.Code != 429 || f.sender.calls != 1 {
t.Fatal("SMS cooldown bypass")
}
mini := context.WithValue(f.ctx, captchaRequestKey{}, captchaRequestInfo{client: "mini_program"})
if f.s.verifyLoginCaptcha(mini, digest([]byte(f.cookie.Value)), "", "") == nil {
t.Fatal("mini captcha bypass")
}
f.s.options.SaaSCaptchaDisabled = false
if f.s.verifyLoginCaptcha(f.ctx, digest([]byte(f.cookie.Value)), "", "") == nil {
t.Fatal("reenabling did not enforce proof")
}
f.s.options.SaaSCaptchaDisabled = true
w, _ = f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: sms["sms_challenge_id"].(string), Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
}
func TestSaaSCaptchaDisabledPassword(t *testing.T) {
f := newLoginFixture(t)
account, csrf := f.smsAccount()
w, _ := f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
f.s.options.SaaSCaptchaDisabled = true
f.newLoginSession()
w, _ = f.request("POST", "/api/v1/web/login/password", PasswordLoginInput{Phone: f.phone, Password: "WrongPassword!"}, f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("wrong password accepted")
}
w, _ = f.request("POST", "/api/v1/web/login/password", PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!"}, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
}
+157
View File
@@ -0,0 +1,157 @@
package onboarding
import (
"context"
"data-terminal-cloud/internal/audit"
"encoding/json"
"net"
"net/http"
"net/netip"
"strings"
)
type captchaRequestKey struct{}
type captchaRequestInfo struct{ client, ip string }
func captchaRequest(ctx context.Context) captchaRequestInfo {
if v, ok := ctx.Value(captchaRequestKey{}).(captchaRequestInfo); ok {
return v
}
return captchaRequestInfo{client: "saas"}
}
// Walk the proxy chain from the trusted peer, never trust the leftmost value
// merely because a client supplied X-Forwarded-For.
func (s *Service) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip, err := netip.ParseAddr(host)
if err != nil {
return ""
}
trusted := func(ip netip.Addr) bool {
for _, cidr := range s.options.TrustedProxyCIDRs {
prefix, e := netip.ParsePrefix(cidr)
if e == nil && prefix.Contains(ip) {
return true
}
}
return false
}
if trusted(ip) {
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
for i := len(parts) - 1; i >= 0 && trusted(ip); i-- {
next, e := netip.ParseAddr(strings.TrimSpace(parts[i]))
if e != nil {
break
}
ip = next
}
}
return ip.String()
}
type captchaPolicy struct {
SaaS string `json:"saas_mode"`
Mini string `json:"mini_mode"`
Version int64 `json:"version"`
}
func (p captchaPolicy) mode(client string) string {
if client == "mini_program" {
return p.Mini
}
return p.SaaS
}
func (s *Service) readCaptchaPolicy(ctx context.Context, q queryer) (captchaPolicy, error) {
var p captchaPolicy
err := q.QueryRow(ctx, `SELECT saas_mode,mini_mode,version FROM login_captcha_policy WHERE singleton`).Scan(&p.SaaS, &p.Mini, &p.Version)
return p, err
}
func (s *Service) captchaAppID(client string) string {
if s.options.Captcha == nil {
return ""
}
return s.options.Captcha.AppID(client)
}
func (s *Service) captchaPolicyResult(p captchaPolicy) map[string]any {
return map[string]any{"saas_mode": p.SaaS, "mini_mode": p.Mini, "version": p.Version, "saas_configured": s.captchaAppID("saas") != "", "mini_configured": s.captchaAppID("mini_program") != "", "saas_disabled": s.options.SaaSCaptchaDisabled}
}
func (s *Service) captchaPolicyHTTP(r *http.Request, requestID string) (any, error) {
if r.URL.RawQuery != "" {
return nil, errAccess
}
p, err := s.operatorView().browserPrincipal(r, r.Method != http.MethodGet)
if err != nil {
return nil, err
}
ctx := r.Context()
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
a, err := operatorAccess(ctx, tx, p, r.Header.Get("X-Tenant-ID"), false)
if err != nil {
return nil, err
}
// This affects all login entry points, so ordinary operators cannot change it.
if a.Role != "ops_admin" {
return nil, errAccess
}
policy, err := s.readCaptchaPolicy(ctx, tx)
if err != nil {
return nil, err
}
if r.Method == http.MethodGet {
return s.captchaPolicyResult(policy), nil
}
raw, err := readBody(r)
if err != nil {
return nil, err
}
var in captchaPolicy
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
if !oneOf(in.SaaS, "image", "slide", "tencent") || !oneOf(in.Mini, "image", "tencent") || in.Version < 1 {
return nil, fail(400, "VALIDATION_FAILED", "请选择有效的验证码方式")
}
if (in.SaaS == "tencent" && s.captchaAppID("saas") == "") || (in.Mini == "tencent" && s.captchaAppID("mini_program") == "") {
return nil, fail(409, "CAPTCHA_NOT_CONFIGURED", "请先在服务器填写对应端的腾讯云验证码配置并重启服务")
}
result, err := tx.Exec(ctx, `UPDATE login_captcha_policy SET saas_mode=$1,mini_mode=$2,version=version+1,updated_at=now() WHERE singleton AND version=$3`, in.SaaS, in.Mini, in.Version)
if err != nil {
return nil, err
}
if result.RowsAffected() != 1 {
return nil, fail(409, "CAPTCHA_POLICY_CONFLICT", "配置已被更新,请刷新后重试")
}
after := in
after.Version++
err = s.audit.Record(ctx, tx, audit.Event{TenantID: &a.TenantID, ActorType: "user", ActorPublicID: &a.PublicID, Action: "login.captcha.policy.update", ResourceType: "login_captcha_policy", RequestID: requestID, Result: "success", Detail: map[string]any{"before": policy, "after": after}})
if err != nil {
return nil, err
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
in.Version++
return s.captchaPolicyResult(in), nil
}
type storedCaptcha struct {
ID string `json:"id"`
Mode string `json:"mode"`
Client string `json:"client"`
Version int64 `json:"version"`
AppID string `json:"app_id,omitempty"`
AnswerHash string `json:"answer_hash,omitempty"`
Solution []byte `json:"slide_solution,omitempty"`
ProofHash string `json:"slide_proof_hash,omitempty"`
ExpiresAt int64 `json:"expires_at_ms,omitempty"`
}
func captchaJSON(v storedCaptcha) string { raw, _ := json.Marshal(v); return string(raw) }
+164
View File
@@ -0,0 +1,164 @@
package onboarding
import (
"bytes"
"context"
"data-terminal-cloud/internal/identity"
"encoding/json"
"github.com/google/uuid"
"net/http/httptest"
"strings"
"testing"
"time"
)
type fakeCaptchaProvider struct {
calls int
client, ip string
err error
}
func (p *fakeCaptchaProvider) AppID(client string) string {
if client == "mini_program" {
return "456"
}
return "123"
}
func (p *fakeCaptchaProvider) Verify(_ context.Context, client, ticket, randstr, ip string) error {
p.calls++
p.client = client
p.ip = ip
return p.err
}
func setCaptchaTestPolicy(t *testing.T, f *fixture, saas, mini string) {
t.Helper()
_, err := f.s.pool.Exec(f.ctx, `UPDATE login_captcha_policy SET saas_mode=$1,mini_mode=$2,version=version+1`, saas, mini)
must(t, err)
t.Cleanup(func() {
_, e := f.s.pool.Exec(f.ctx, `UPDATE login_captcha_policy SET saas_mode='image',mini_mode='image',version=version+1`)
if e != nil {
t.Error(e)
}
})
}
func TestCaptchaPolicyTencentSendCannotBypassOrReplay(t *testing.T) {
f := newLoginFixture(t)
p := &fakeCaptchaProvider{}
f.s.options.Captcha = p
setCaptchaTestPolicy(t, f.fixture, "tencent", "image")
w, ch := f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
if w.Code != 200 || ch["mode"] != "tencent" || ch["captcha_app_id"] != "123" || ch["image"] != nil {
t.Fatal(w.Body.String())
}
in := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: ch["captcha_id"].(string), CaptchaCode: "654321"}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, "")
if w.Code != 401 || p.calls != 0 || f.sender.calls != 0 {
t.Fatal("numeric answer bypassed tencent")
}
_, ch = f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
in.CaptchaID = ch["captcha_id"].(string)
in.CaptchaTicket = uuid.NewString()
in.CaptchaRandstr = "random"
in.CaptchaCode = ""
req := uuid.NewString()
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, req)
if w.Code != 200 || p.calls != 1 || p.client != "saas" || p.ip != strings.Split(f.ip, ":")[0] || f.sender.calls != 1 {
t.Fatal(w.Body.String())
}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, req)
if w.Code != 200 || p.calls != 1 || f.sender.calls != 1 {
t.Fatal("idempotency failed")
}
_, ch = f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
in.CaptchaID = ch["captcha_id"].(string)
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, "")
if w.Code != 401 || p.calls != 1 || f.sender.calls != 1 {
t.Fatal("ticket replay accepted")
}
}
func TestCaptchaPolicyClientSeparationExpiryAndCloudFailure(t *testing.T) {
f := newLoginFixture(t)
p := &fakeCaptchaProvider{}
f.s.options.Captcha = p
setCaptchaTestPolicy(t, f.fixture, "image", "tencent")
webctx := context.WithValue(f.ctx, captchaRequestKey{}, captchaRequestInfo{client: "saas", ip: "203.0.113.1"})
mini := context.WithValue(f.ctx, captchaRequestKey{}, captchaRequestInfo{client: "mini_program", ip: "203.0.113.2"})
hash := digest([]byte(f.cookie.Value))
ch, err := f.s.loginCaptcha(mini, hash)
must(t, err)
if ch["captcha_app_id"] != "456" {
t.Fatal("mixed app ids")
}
if f.s.verifyLoginCaptcha(webctx, hash, ch["captcha_id"].(string), "", uuid.NewString(), "") == nil {
t.Fatal("cross-client bypass")
}
ch, err = f.s.loginCaptcha(mini, hash)
must(t, err)
_, err = f.s.redis.Expire(f.ctx, f.s.captchaKey(hash), -time.Second).Result()
must(t, err)
if f.s.verifyLoginCaptcha(mini, hash, ch["captcha_id"].(string), "", uuid.NewString(), "") == nil {
t.Fatal("expired accepted")
}
ch, err = f.s.loginCaptcha(mini, hash)
must(t, err)
p.err = captchaUnavailable()
if f.s.verifyLoginCaptcha(mini, hash, ch["captcha_id"].(string), "", uuid.NewString(), "") == nil || p.client != "mini_program" {
t.Fatal("cloud failure accepted")
}
ch, err = f.s.loginCaptcha(mini, hash)
must(t, err)
p.err = nil
_, err = f.s.pool.Exec(f.ctx, `UPDATE login_captcha_policy SET version=version+1`)
must(t, err)
if f.s.verifyLoginCaptcha(mini, hash, ch["captcha_id"].(string), "", uuid.NewString(), "") == nil {
t.Fatal("old policy accepted")
}
}
type captchaPolicyToken struct{ p identity.Principal }
func (v captchaPolicyToken) Verify(context.Context, string) (identity.Principal, error) {
return v.p, nil
}
func TestCaptchaPolicyAdminOnlyReadinessVersionAndAudit(t *testing.T) {
f := newFixture(t)
setCaptchaTestPolicy(t, f, "image", "image")
f.s.options.TokenVerifier = captchaPolicyToken{f.p}
call := func(method string, body any) (any, error) {
raw, _ := json.Marshal(body)
r := httptest.NewRequest(method, "https://platform.example/api/v1/ops/login-captcha-policy", bytes.NewReader(raw))
r.Header.Set("Authorization", "Bearer test")
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Tenant-ID", f.tenantPublic)
return f.s.captchaPolicyHTTP(r, uuid.NewString())
}
out, err := call("GET", nil)
must(t, err)
policy := out.(map[string]any)
in := captchaPolicy{SaaS: "tencent", Mini: "image", Version: policy["version"].(int64)}
if _, err = call("POST", in); err == nil {
t.Fatal("unconfigured provider accepted")
}
f.s.options.Captcha = &fakeCaptchaProvider{}
out, err = call("POST", in)
must(t, err)
if out.(map[string]any)["saas_mode"] != "tencent" {
t.Fatal("not persisted")
}
if _, err = call("POST", in); err == nil {
t.Fatal("stale version accepted")
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM audit_event WHERE tenant_id=$1 AND action='login.captcha.policy.update'`, f.tenant).Scan(&count))
if count != 1 {
t.Fatal("missing audit")
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE user_account SET platform_role='ops_operator' WHERE id=$1`, f.operatorID)
must(t, err)
if _, err = call("GET", nil); err == nil {
t.Fatal("nonadmin read")
}
if _, err = call("POST", captchaPolicy{SaaS: "image", Mini: "image", Version: in.Version + 1}); err == nil {
t.Fatal("nonadmin write")
}
}
+14
View File
@@ -89,6 +89,20 @@ func keyAAD(device string, version int64) string {
return fmt.Sprintf("ONBOARDING-KEY1\n%s\n%d", device, version)
}
// SealDeviceKey stores a device HMAC key in the same format consumed by the
// physical-confirm authentication path. The plaintext key is returned to the
// caller only at device creation or explicit rotation time.
func SealDeviceKey(masterKey, deviceID string, version int64, key []byte) ([]byte, error) {
if len(key) != 32 || !uuidValid(deviceID) || version < 1 {
return nil, errors.New("invalid onboarding device key input")
}
crypt, err := newCryptor(masterKey)
if err != nil {
return nil, err
}
return crypt.seal(key, keyAAD(deviceID, version)), nil
}
// 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 {
+17
View File
@@ -1,9 +1,11 @@
package onboarding
import (
"bytes"
"encoding/hex"
"encoding/json"
"os"
"strings"
"testing"
)
@@ -33,3 +35,18 @@ func TestStrictJSONRejectsAmbiguousBodies(t *testing.T) {
}
}
}
func TestSealDeviceKeyMatchesAuthenticationStorageFormat(t *testing.T) {
const deviceID = "11111111-1111-4111-8111-111111111111"
masterKey := strings.Repeat("15", 32)
key := bytes.Repeat([]byte{0x5a}, 32)
sealed, err := SealDeviceKey(masterKey, deviceID, 3, key)
must(t, err)
crypt, err := newCryptor(masterKey)
must(t, err)
opened, err := crypt.open(sealed, keyAAD(deviceID, 3))
must(t, err)
if !bytes.Equal(opened, key) {
t.Fatal("sealed device key did not round-trip through onboarding authentication format")
}
}
+179
View File
@@ -0,0 +1,179 @@
package onboarding
import (
"context"
"crypto/hmac"
"crypto/sha256"
"errors"
"net/http"
"strings"
"time"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
func (s *Service) loginCookieName() string {
if s.operatorLogin {
if s.options.AllowLocalHTTP {
return "platform-operator-login"
}
return "__Host-platform-operator-login"
}
if s.options.AllowLocalHTTP {
return "platform-login"
}
return "__Host-platform-login"
}
// Stable for one cookie, so loading another tab does not invalidate its CSRF token.
func (s *Service) sessionCSRF(domain, token string) string {
m := hmac.New(sha256.New, s.crypt.hashKey)
m.Write([]byte(domain + "\n" + token))
return encode64(m.Sum(nil))
}
func (s *Service) customerLoginHTTP(w http.ResponseWriter, r *http.Request, requestID string) (any, error) {
if r.URL.RawQuery != "" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
return nil, errAccess
}
if r.Method == http.MethodGet {
token := ""
if c, err := r.Cookie(s.loginCookieName()); err == nil && validB64(c.Value, 32) {
var live bool
if err = s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM `+s.loginSessionTable()+` WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now())`, digest([]byte(c.Value))).Scan(&live); err != nil {
return nil, err
}
if live {
token = c.Value
}
}
if token == "" {
token = randomToken(32)
_, err := s.pool.Exec(r.Context(), `INSERT INTO `+s.loginSessionTable()+`(token_hash,csrf_hash,expires_at) VALUES($1,$2,$3)`, digest([]byte(token)), digest([]byte(s.sessionCSRF("LOGIN-CSRF1", token))), s.now().Add(20*time.Minute))
if err != nil {
return nil, err
}
http.SetCookie(w, &http.Cookie{Name: s.loginCookieName(), Value: token, Path: "/", HttpOnly: true, Secure: !s.options.AllowLocalHTTP, SameSite: http.SameSiteLaxMode, MaxAge: 1200})
}
return map[string]any{"sms_enabled": s.options.SMSLoginEnabled, "password_enabled": true, "signup_enabled": !s.operatorLogin && len(s.options.SelfSignupTenantIDs) == 1, "csrf_token": s.sessionCSRF("LOGIN-CSRF1", token)}, nil
}
if !s.sameOrigin(r) {
return nil, errAccess
}
cookie, err := r.Cookie(s.loginCookieName())
if err != nil || !validB64(cookie.Value, 32) {
return nil, fail(401, "LOGIN_SESSION_EXPIRED", "登录页面已过期,请刷新后重新获取验证码")
}
if !hmac.Equal([]byte(r.Header.Get("X-CSRF-Token")), []byte(s.sessionCSRF("LOGIN-CSRF1", cookie.Value))) {
return nil, errAccess
}
hash := digest([]byte(cookie.Value))
raw, err := readBody(r)
if err != nil {
return nil, err
}
check := func(tx pgx.Tx) error { return s.checkLoginSession(r.Context(), tx, hash) }
switch {
case strings.HasSuffix(r.URL.Path, "/captcha/verify"):
return decodeCall(raw, func(in SlideCaptchaVerifyInput) (map[string]any, error) {
return s.verifySlideCaptcha(r.Context(), hash, in, check)
})
case strings.HasSuffix(r.URL.Path, "/captcha"):
if err = StrictJSON(raw, &struct{}{}); err != nil {
return nil, err
}
return s.loginCaptcha(r.Context(), hash)
case strings.HasSuffix(r.URL.Path, "/password"):
var in PasswordLoginInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
// Browser trust comes exclusively from its HttpOnly cookie.
in.TrustedDeviceToken = s.trustedLoginCookie(r)
out, err := s.passwordLogin(r.Context(), hash, in)
if err == nil {
s.completeCustomerLogin(w, out)
}
return out, err
case strings.HasSuffix(r.URL.Path, "/sms/send"):
return decodeCall(raw, func(in SMSSendInput) (map[string]any, error) {
return s.sendSMS(r.Context(), hash, requestID, in, s.loginSMSTable(), check)
})
case strings.HasSuffix(r.URL.Path, "/sms/verify"):
var in SMSVerifyInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
out, err := s.verifySMS(r.Context(), hash, in, s.loginSMSTable(), check, func(tx pgx.Tx, p identity.Principal) (BrowserCredentials, error) {
return s.attachCustomerLogin(r.Context(), tx, hash, p, true)
})
if err == nil {
s.completeCustomerLogin(w, out)
}
return out, err
}
return nil, fail(404, "NOT_FOUND", "接口不存在")
}
func (s *Service) checkLoginSession(ctx context.Context, tx pgx.Tx, hash string) error {
var expires time.Time
err := tx.QueryRow(ctx, `SELECT expires_at FROM `+s.loginSessionTable()+` WHERE token_hash=$1 AND consumed_at IS NULL FOR UPDATE`, hash).Scan(&expires)
if errors.Is(err, pgx.ErrNoRows) || (err == nil && !expires.After(s.now())) {
return fail(401, "LOGIN_SESSION_EXPIRED", "登录页面已过期,请刷新后重新获取验证码")
}
return err
}
func (s *Service) completeCustomerLogin(w http.ResponseWriter, out BrowserCredentials) {
s.issueCookies(w, out)
http.SetCookie(w, &http.Cookie{Name: s.loginCookieName(), Value: "", Path: "/", HttpOnly: true, Secure: !s.options.AllowLocalHTTP, SameSite: http.SameSiteLaxMode, MaxAge: -1})
}
func (s *Service) attachCustomerLogin(ctx context.Context, tx pgx.Tx, hash string, p identity.Principal, allowSignup bool) (BrowserCredentials, error) {
var out BrowserCredentials
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "customer-sms:"+p.Subject); err != nil {
return out, err
}
var user string
err := tx.QueryRow(ctx, `SELECT ua.id::text FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND ua.status='active' AND `+s.accountPredicate()+` ORDER BY ua.created_at,ua.id LIMIT 1 FOR SHARE OF ua,t`, p.Issuer, p.Subject).Scan(&user)
if errors.Is(err, pgx.ErrNoRows) {
if !allowSignup || s.operatorLogin {
return out, fail(403, "ACCOUNT_UNAVAILABLE", "当前账号暂不可用,请联系管理员")
}
// A disabled identity must not silently become a new account elsewhere.
var exists bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM user_account WHERE oidc_issuer=$1 AND oidc_subject=$2)`, p.Issuer, p.Subject).Scan(&exists); err != nil {
return out, err
}
if exists {
return out, fail(403, "ACCOUNT_UNAVAILABLE", "当前账号暂不可用,请联系管理员")
}
// Signup tenant comes only from deployment configuration, never request JSON.
if len(s.options.SelfSignupTenantIDs) != 1 {
return out, fail(403, "SMS_ACCOUNT_NOT_REGISTERED", "此手机号尚未开通,请先通过设备绑定入口注册或联系管理员")
}
var tenant string
err = tx.QueryRow(ctx, `SELECT id::text FROM tenant WHERE public_id=$1::uuid AND status='active' FOR SHARE`, s.options.SelfSignupTenantIDs[0]).Scan(&tenant)
if errors.Is(err, pgx.ErrNoRows) {
return out, fail(403, "ACCOUNT_UNAVAILABLE", "当前服务暂未开放注册")
}
if err != nil {
return out, err
}
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`, tenant, p.Issuer, p.Subject, p.DisplayName).Scan(&user)
}
if err != nil {
return out, err
}
out.AccountToken = randomToken(32)
out.CSRF = s.sessionCSRF("ACCOUNT-CSRF1", out.AccountToken)
if _, err = tx.Exec(ctx, `INSERT INTO `+s.accountSessionTable()+`(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)); err != nil {
return out, err
}
if _, err = tx.Exec(ctx, `UPDATE `+s.loginSessionTable()+` SET consumed_at=now() WHERE token_hash=$1`, hash); err != nil {
return out, err
}
out.Data = map[string]any{"authenticated": true}
return out, nil
}
+384
View File
@@ -0,0 +1,384 @@
package onboarding
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
type loginFixture struct {
*fixture
mux *http.ServeMux
sender *captureSMS
cookie *http.Cookie
trusted *http.Cookie
csrf, phone, ip string
}
func newLoginFixture(t *testing.T) *loginFixture {
f := &loginFixture{fixture: newFixture(t), sender: &captureSMS{}, mux: http.NewServeMux()}
u := uuid.New()
f.phone = fmt.Sprintf("+86138%08d", uint64(u.ID())%100000000)
f.ip = fmt.Sprintf("198.18.%d.%d:1234", u[0], u[1])
f.s.options.SMS = f.sender
f.s.options.SMSLoginEnabled = true
f.s.options.Enabled = false // Website login is independent of device enrollment.
f.s.RegisterRoutes(f.mux)
f.mux.Handle("POST /customer-write", f.s.CustomerAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })))
r, data := f.request("GET", "/api/v1/web/login/session", nil, nil, "", "")
if r.Code != 200 {
t.Fatal(r.Body.String())
}
f.cookie = r.Result().Cookies()[0]
f.csrf = data["csrf_token"].(string)
if !f.cookie.Secure || !f.cookie.HttpOnly || f.cookie.SameSite != http.SameSiteLaxMode {
t.Fatal("login cookie protections missing")
}
return f
}
func (f *loginFixture) request(method, path string, body any, cookie *http.Cookie, csrf, id string) (*httptest.ResponseRecorder, map[string]any) {
f.t.Helper()
if f.s.operatorLogin && (strings.HasPrefix(path, "/api/v1/web/login/") || strings.HasPrefix(path, "/api/v1/web/account/") || path == "/api/v1/web/auth/logout") {
path = strings.Replace(path, "/api/v1/web/", "/api/v1/web/operator/", 1)
}
raw, _ := json.Marshal(body)
r := httptest.NewRequest(method, "https://platform.example"+path, bytes.NewReader(raw))
r.RemoteAddr = f.ip
r.Header.Set("Origin", "https://platform.example")
r.Header.Set("Content-Type", "application/json")
if id == "" {
id = uuid.NewString()
}
r.Header.Set("X-Request-ID", id)
r.Header.Set("X-CSRF-Token", csrf)
if cookie != nil {
r.AddCookie(cookie)
}
if f.trusted != nil {
r.AddCookie(f.trusted)
}
w := httptest.NewRecorder()
f.mux.ServeHTTP(w, r)
for _, c := range w.Result().Cookies() {
if c.Name == f.s.trustedLoginCookieName() {
f.trusted = c
}
}
var envelope struct {
Data map[string]any `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &envelope)
return w, envelope.Data
}
func (f *loginFixture) send() string {
f.t.Helper()
r, data := f.request("POST", "/api/v1/web/login/sms/send", f.smsInput(), f.cookie, f.csrf, "")
if r.Code != 200 {
f.t.Fatal(r.Body.String())
}
if strings.Contains(r.Body.String(), f.sender.code) || strings.Contains(r.Body.String(), f.phone) {
f.t.Fatal("OTP or phone exposed in response")
}
return data["sms_challenge_id"].(string)
}
func (f *loginFixture) smsInput() SMSSendInput {
return SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: f.knownCaptcha(), CaptchaCode: "654321"}
}
func TestCustomerSMSLoginWithoutDeviceAndCookieLifecycle(t *testing.T) {
f := newLoginFixture(t)
id := f.send()
r, data := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if r.Code != 200 {
t.Fatal(r.Body.String())
}
var account *http.Cookie
for _, c := range r.Result().Cookies() {
if c.Name == f.s.cookieName(true) {
account = c
}
}
if account == nil || !account.HttpOnly || !account.Secure || account.MaxAge != 86400 {
t.Fatal("account cookie missing or unsafe")
}
csrf := data["csrf_token"].(string)
p, err := f.s.AccountPrincipal(f.ctx, account.Value, csrf, true)
must(t, err)
if p.Issuer != "urn:platform:onboarding:sms" {
t.Fatal("incorrect principal")
}
var role, kind string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT platform_role,account_kind FROM user_account WHERE oidc_issuer=$1 AND oidc_subject=$2`, p.Issuer, p.Subject).Scan(&role, &kind))
if role != "customer" || kind != "customer" {
t.Fatal("SMS login elevated privileges")
}
// Reloading another tab must not invalidate an existing page's CSRF token.
for i := 0; i < 2; i++ {
w, session := f.request("GET", "/api/v1/web/account/session", nil, account, "", "")
if w.Code != 200 || session["csrf_token"] != csrf {
t.Fatal("session reload changed CSRF")
}
}
w, _ := f.request("POST", "/customer-write", struct{}{}, account, csrf, "")
if w.Code != 204 {
t.Fatal("valid customer write refused", w.Body.String())
}
w, _ = f.request("POST", "/customer-write", struct{}{}, account, "wrong", "")
if w.Code != 403 {
t.Fatal("missing CSRF accepted")
}
w, _ = f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("consumed login replayed")
}
w, _ = f.request("POST", "/api/v1/web/auth/logout", struct{}{}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
w, _ = f.request("POST", "/customer-write", struct{}{}, account, csrf, "")
if w.Code != 401 {
t.Fatal("logged-out cookie remained valid")
}
}
func TestCustomerSMSDeliveryIdempotenceAndSharedPhoneLimit(t *testing.T) {
f := newLoginFixture(t)
requestID := uuid.NewString()
in := f.smsInput()
w, first := f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, requestID)
if w.Code != 200 {
t.Fatal(w.Body.String())
}
w, again := f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, requestID)
if w.Code != 200 || first["sms_challenge_id"] != again["sms_challenge_id"] || f.sender.calls != 1 {
t.Fatal("duplicate delivery")
}
in.CaptchaID = f.knownCaptcha()
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, "")
if w.Code != 429 {
t.Fatal("cooldown ignored")
}
in.CaptchaID = f.knownCaptchaFor(f.web.Hash)
_, err := f.s.SendSMS(f.ctx, f.web, uuid.NewString(), in)
var e *Error
if !errors.As(err, &e) || e.Status != 429 {
t.Fatal("device signup bypassed phone limit", err)
}
}
func TestCustomerSMSWrongCodeLimitAndExpiry(t *testing.T) {
f := newLoginFixture(t)
id := f.send()
wrong := "000000"
if wrong == f.sender.code {
wrong = "999999"
}
for i := 0; i < 5; i++ {
w, _ := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: wrong}, f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("wrong OTP accepted")
}
}
w, _ := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("attempt cap ignored")
}
_, err := f.s.pool.Exec(f.ctx, `UPDATE customer_sms_challenge SET attempts=0,expires_at=now()-interval '1 second' WHERE id=$1::uuid`, id)
must(t, err)
w, _ = f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("expired OTP accepted")
}
}
func TestCustomerSMSRejectsOtherBrowserAndCrossOrigin(t *testing.T) {
f := newLoginFixture(t)
id := f.send()
w, data := f.request("GET", "/api/v1/web/login/session", nil, nil, "", "")
other := w.Result().Cookies()[0]
w, _ = f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, other, data["csrf_token"].(string), "")
if w.Code != 401 {
t.Fatal("OTP from another browser accepted")
}
r := httptest.NewRequest("POST", "https://platform.example/api/v1/web/login/sms/send", strings.NewReader(`{}`))
r.RemoteAddr = f.ip
r.AddCookie(f.cookie)
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Request-ID", uuid.NewString())
r.Header.Set("X-CSRF-Token", f.csrf)
r.Header.Set("Origin", "https://other.example")
w = httptest.NewRecorder()
f.mux.ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal("cross-origin login accepted")
}
}
type failingLoginSMS struct{}
func TestCustomerSMSLoginResumesDeviceSetup(t *testing.T) {
f := newLoginFixture(t)
f.s.options.Enabled = true
var localID 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(&localID))
verifier := randomToken(32)
sum := sha256.Sum256([]byte(verifier))
in := HandoffInput{SetupID: f.setup, LocalID: localID, Challenge: encode64(sum[:])}
handoff, err := f.s.Handoff(f.ctx, f.d, uuid.NewString(), requestJSON(in), in)
must(t, err)
setup, err := f.s.Exchange(f.ctx, ExchangeInput{Token: handoff["handoff_token"].(string), Verifier: verifier})
must(t, err)
id := f.send()
login, _ := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if login.Code != 200 {
t.Fatal(login.Body.String())
}
var account *http.Cookie
for _, c := range login.Result().Cookies() {
if c.Name == f.s.cookieName(true) {
account = c
}
}
if account == nil {
t.Fatal("missing account cookie")
}
link := func(origin, csrf string, withAccount bool) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "https://platform.example/api/v1/web/setup/auth-link", strings.NewReader(`{}`))
r.RemoteAddr = f.ip
r.Header.Set("Origin", origin)
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Request-ID", uuid.NewString())
r.Header.Set("X-CSRF-Token", csrf)
r.AddCookie(&http.Cookie{Name: f.s.cookieName(false), Value: setup.Token})
if withAccount {
r.AddCookie(account)
}
w := httptest.NewRecorder()
f.mux.ServeHTTP(w, r)
return w
}
if w := link("https://other.example", setup.CSRF, true); w.Code != 403 {
t.Fatal("cross-origin setup link accepted", w.Body.String())
}
if w := link("https://platform.example", "wrong", true); w.Code != 403 {
t.Fatal("invalid setup CSRF accepted", w.Body.String())
}
if w := link("https://platform.example", setup.CSRF, false); w.Code != 401 {
t.Fatal("anonymous setup link accepted", w.Body.String())
}
w := link("https://platform.example", setup.CSRF, true)
if w.Code != 200 {
t.Fatal(w.Body.String())
}
var linked *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == f.s.cookieName(false) {
linked = c
}
}
if linked == nil {
t.Fatal("setup cookie was not rotated")
}
ws, err := f.s.webSession(f.ctx, f.s.pool, digest([]byte(linked.Value)))
must(t, err)
p, err := f.s.AccountPrincipal(f.ctx, account.Value, "", false)
must(t, err)
if ws.SetupID != f.setup || ws.UserID == "" || ws.Issuer != p.Issuer || ws.Subject != p.Subject {
t.Fatal("setup did not resume under the SMS identity")
}
if _, err := f.s.webSession(f.ctx, f.s.pool, digest([]byte(setup.Token))); err == nil {
t.Fatal("old setup cookie remained valid")
}
}
func (failingLoginSMS) Send(context.Context, string, string, string) error {
return errors.New("provider timeout")
}
func TestCustomerSMSUnavailableDeliveryAndNoAutomaticRetry(t *testing.T) {
f := newLoginFixture(t)
f.s.options.SMS = failingLoginSMS{}
w, _ := f.request("POST", "/api/v1/web/login/sms/send", f.smsInput(), f.cookie, f.csrf, "")
if w.Code != 503 || !strings.Contains(w.Body.String(), "SMS_SEND_UNCERTAIN") {
t.Fatal("unconfirmed SMS reported delivered")
}
var status string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT status FROM customer_sms_challenge WHERE session_hash=$1`, digest([]byte(f.cookie.Value))).Scan(&status))
if status != "FAILED" {
t.Fatal(status)
}
f.s.options.SMSLoginEnabled = false
w, _ = f.request("POST", "/api/v1/web/login/sms/send", f.smsInput(), f.cookie, f.csrf, "")
if w.Code != 503 {
t.Fatal("disabled SMS accepted")
}
}
func TestCustomerSMSDoesNotProvisionDisabledOrUnscopedAccounts(t *testing.T) {
for _, disabled := range []bool{false, true} {
t.Run(fmt.Sprintf("disabled=%v", disabled), func(t *testing.T) {
f := newLoginFixture(t)
id := f.send()
phoneHash := domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone)
if disabled {
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO user_account(tenant_id,oidc_issuer,oidc_subject,display_name,platform_role,account_kind,status) VALUES($1::uuid,'urn:platform:onboarding:sms',$2,'disabled test','customer','customer','disabled')`, f.tenant, phoneHash)
must(t, err)
} else {
f.s.options.SelfSignupTenantIDs = nil
}
w, _ := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 403 {
t.Fatal("unavailable identity created a new account", w.Body.String())
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM user_account WHERE oidc_issuer='urn:platform:onboarding:sms' AND oidc_subject=$1 AND status='active'`, phoneHash).Scan(&count))
if count != 0 {
t.Fatal("unexpected account provisioned")
}
})
}
}
func TestCustomerSMSExistingAccountAndExpiredSession(t *testing.T) {
f := newLoginFixture(t)
id := f.send()
digestPhone := domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone)
user := uuid.NewString()
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO user_account(id,tenant_id,oidc_issuer,oidc_subject,display_name,platform_role,account_kind) VALUES($1::uuid,$2::uuid,'urn:platform:onboarding:sms',$3,'已有手机账号','customer','customer')`, user, f.tenant, digestPhone)
must(t, err)
f.s.options.SelfSignupTenantIDs = nil
w, _ := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal("existing account required signup tenant", w.Body.String())
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM user_account WHERE oidc_issuer='urn:platform:onboarding:sms' AND oidc_subject=$1`, digestPhone).Scan(&count))
if count != 1 {
t.Fatal("existing account duplicated")
}
// Expired cookies must return 401, so the portal clears private content.
_, err = f.s.pool.Exec(f.ctx, `UPDATE onboarding_account_session SET expires_at=$2 WHERE user_id=$1::uuid`, user, time.Now().Add(-time.Second))
must(t, err)
for _, cookie := range w.Result().Cookies() {
if cookie.Name == f.s.cookieName(true) {
_, err = f.s.AccountPrincipal(f.ctx, cookie.Value, "", false)
var e *Error
if !errors.As(err, &e) || e.Status != 401 {
t.Fatal("expired account accepted", err)
}
}
}
}
+57 -10
View File
@@ -11,9 +11,11 @@ import (
"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.
// Production registers in the worker. The legacy path is retained for migration tests.
func (s *Service) ProcessActivation(ctx context.Context, event outbox.Event) error {
if !s.options.LegacyDeviceRegistration {
return s.processServerActivation(ctx, event)
}
if !s.options.ActivationEnabled {
return outbox.Retryable(errors.New("onboarding activation paused"))
}
@@ -42,7 +44,7 @@ func (s *Service) ProcessActivation(ctx context.Context, event outbox.Event) err
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 {
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) OR EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND generation=$2 AND released_at IS NULL)`, d.ID, d.Generation).Scan(&bound); err != nil {
return err
}
if !bound {
@@ -62,7 +64,7 @@ func (s *Service) ProcessActivation(ctx context.Context, event outbox.Event) err
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))
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 IN ('DEVICE','SERVER')`, j.ID, s.now().Add(-90*time.Second))
if err != nil {
return err
}
@@ -78,11 +80,26 @@ func (s *Service) expireAttempt(ctx context.Context, tx pgx.Tx, d Device, j *act
}
func (s *Service) ensureConfig(ctx context.Context, tx pgx.Tx, d Device, j *activationJob) error {
if !s.options.LegacyDeviceRegistration && (j.Registered == nil || len(j.Secret) == 0 || j.RTCAppID == "") {
return nil
}
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.LegacyDeviceRegistration {
return nil
}
cached, e := s.crypt.open(j.Config, "config:"+j.ID)
if e != nil {
return e
}
var old struct {
IoT map[string]any `json:"iot"`
}
if json.Unmarshal(cached, &old) == nil && old.IoT["product_secret"] == nil && old.IoT["device_secret"] != nil {
return nil
}
}
if !s.options.ActivationEnabled {
return fail(503, "ACTIVATION_PAUSED", "激活暂未开放")
@@ -111,7 +128,17 @@ func (s *Service) ensureConfig(ctx context.Context, tx pgx.Tx, d Device, j *acti
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})
iot := map[string]any{"instance_id": j.Instance, "product_key": j.Product, "device_name": j.Name}
if s.options.LegacyDeviceRegistration {
iot["product_secret"] = s.options.ProductSecret
} else {
secret, e := s.crypt.open(j.Secret, "provider:"+j.ID)
if e != nil {
return e
}
iot["device_secret"], iot["RTCAppID"] = string(secret), j.RTCAppID
}
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": iot, "bot_id": j.Bot})
if err != nil {
return err
}
@@ -122,7 +149,11 @@ func (s *Service) ensureConfig(ctx context.Context, tx pgx.Tx, d Device, j *acti
j.Status = "RESERVED"
if j.Registered != nil {
j.Status = "REGISTERED_OBSERVED"
j.Engine = j.Registered
if s.options.LegacyDeviceRegistration {
j.Engine = j.Registered
} else {
j.Engine = nil
}
}
_, 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 {
@@ -164,6 +195,9 @@ func (s *Service) activationCommand(ctx context.Context, tx pgx.Tx, d Device) (a
if !s.options.ActivationEnabled || !oneOf(j.Status, "NOT_STARTED", "RESERVED", "REGISTERED_OBSERVED", "READY") {
return nil, nil
}
if !s.options.LegacyDeviceRegistration && (j.Registered == nil || len(j.Secret) == 0 || j.RTCAppID == "") {
return nil, nil
}
if err = s.ensureConfig(ctx, tx, d, &j); err != nil {
return nil, err
}
@@ -219,7 +253,7 @@ func (s *Service) ActivationAck(ctx context.Context, d Device, id string, raw []
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)
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET config_ack_at=COALESCE(config_ack_at,now()),engine_ready_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
}
@@ -285,6 +319,9 @@ func (s *Service) Attempt(ctx context.Context, d Device, id string, raw []byte,
return nil, fail(429, "ACTIVATION_ATTEMPT_LIMIT", "激活尝试过于频繁")
}
stage := "SDK_REGISTER"
if !s.options.LegacyDeviceRegistration && j.Registered == nil {
return nil, fail(409, "SERVER_REGISTRATION_REQUIRED", "火山注册由平台执行,请获取语音配置")
}
if j.Registered != nil {
if j.Ack == nil {
return nil, fail(409, "ACTIVATION_STAGE_NOT_READY", "请先保存并确认注册结果")
@@ -336,8 +373,8 @@ func (s *Service) ActivationResult(ctx context.Context, d Device, id string, raw
}
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)
var stored, conversation, callSession *string
err = tx.QueryRow(ctx, `SELECT stage,status,binding_generation,config_version,result_digest,conversation_id::text,call_session_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, &callSession)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
@@ -369,6 +406,16 @@ func (s *Service) ActivationResult(ctx context.Context, d Device, id string, raw
return nil, err
}
}
if callSession != nil {
var valid bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM call_session c JOIN call_event a ON a.session_id=c.id AND a.event_type='MEDIA_CONNECTED' JOIN call_event b ON b.session_id=c.id AND b.event_type='FIRST_AUDIO_RECEIVED' JOIN call_event p ON p.session_id=c.id AND p.event_type='FIRST_PLAYBACK_STARTED' WHERE c.id=$1::uuid AND c.device_id=$2::uuid AND c.binding_generation=$3 AND c.purpose='FIRST_TEST' AND c.call_state='ENDED' AND c.local_audio_off AND c.sdk_stopped AND a.seq<b.seq AND b.seq<=p.seq AND c.ended_at-c.created_at<=interval '90 seconds')`, *callSession, current.ID, current.Generation).Scan(&valid)
if err != nil {
return nil, err
}
if !valid {
return nil, fail(409, "TEST_EVIDENCE_REQUIRED", "首通尚缺少连接、首音频、首播放或停止证据")
}
}
}
_, 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 {
+229
View File
@@ -0,0 +1,229 @@
package onboarding
import (
"context"
"encoding/json"
"errors"
"strings"
"data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/deviceconfig"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// These routes use DEVREQ1 factory authentication. A device never supplies the
// platform UUID or tenant used to read configurations or acknowledge commands.
type DeviceControlReport struct {
Firmware string `json:"firmware_version"`
Revision int64 `json:"reported_revision"`
Speaker int `json:"speaker"`
Busy bool `json:"busy"`
RSSI int `json:"rssi"`
DisplayName *string `json:"display_name,omitempty"`
Power *DevicePowerReport `json:"power,omitempty"`
ScreenWake *DeviceScreenWake `json:"screen_wake,omitempty"`
}
type DeviceScreenWake struct {
ID string `json:"id"`
Revision int64 `json:"revision"`
}
type DeviceScreenWakeReceipt struct {
DeviceScreenWake
Result string `json:"result"`
}
type DevicePowerReport struct {
Mode string `json:"mode"`
MicrophonePaused bool `json:"microphone_paused"`
BacklightPercent int `json:"backlight_percent"`
MicrophoneReads uint64 `json:"microphone_reads"`
}
func controlIdentity(d Device) deviceauth.Identity {
return deviceauth.Identity{ID: d.ID, PublicID: d.PublicID, TenantID: d.TenantID,
Status: d.Status, ActivationState: d.Lifecycle, EnrollmentMode: d.Mode,
BindingGeneration: d.Generation}
}
func validateControlReport(in DeviceControlReport) error {
if wake := in.ScreenWake; wake != nil {
if _, err := uuid.Parse(wake.ID); err != nil || len(wake.ID) != 36 || wake.Revision <= 0 ||
in.Power == nil || in.Power.Mode != "active" {
return fail(400, "INVALID_CONTROL_REPORT", "屏幕唤醒状态不正确")
}
}
if p := in.Power; p != nil {
if p.BacklightPercent < -1 || p.BacklightPercent > 100 || p.MicrophoneReads > 4294967295 ||
(p.Mode != "active" && p.Mode != "standby" && p.Mode != "transition") ||
(p.Mode == "standby" && (!p.MicrophonePaused || p.BacklightPercent != 0)) ||
(p.Mode == "active" && (p.MicrophonePaused || p.BacklightPercent <= 0)) {
return fail(400, "INVALID_CONTROL_REPORT", "待机状态不正确")
}
}
if in.DisplayName != nil && !deviceconfig.ValidDisplayName(*in.DisplayName) {
return fail(400, "INVALID_CONTROL_REPORT", "设备名称不正确")
}
if in.Firmware == "" || len(in.Firmware) > 80 || strings.TrimSpace(in.Firmware) != in.Firmware ||
in.Revision < 0 || in.Speaker < 0 || in.Speaker > 100 || in.RSSI < -127 || in.RSSI > 0 {
return fail(400, "INVALID_CONTROL_REPORT", "设备运行状态不正确")
}
return nil
}
// An authenticated physical wake only changes power. A revision fence prevents
// an offline gesture or a lost-response retry from undoing newer cloud settings.
// Its receipt and the actual report commit with the desired state under one lock.
func reconcileScreenWake(ctx context.Context, tx pgx.Tx, d Device, wake *DeviceScreenWake) (*DeviceScreenWakeReceipt, error) {
if wake == nil {
return nil, nil
}
var revision int64
var desired, previous []byte
if err := tx.QueryRow(ctx, `SELECT desired_revision,desired_config,
COALESCE(reported_config->'screen_wake','null'::jsonb)
FROM device_twin WHERE device_id=$1 AND tenant_id=$2 FOR UPDATE`, d.ID, d.TenantID).Scan(&revision, &desired, &previous); err != nil {
return nil, err
}
var receipt DeviceScreenWakeReceipt
if json.Unmarshal(previous, &receipt) == nil && receipt.ID == wake.ID {
if receipt.Revision != wake.Revision {
return nil, fail(409, "SCREEN_WAKE_CONFLICT", "唤醒请求已变化")
}
return &receipt, nil
}
receipt = DeviceScreenWakeReceipt{DeviceScreenWake: *wake, Result: "superseded"}
if revision != wake.Revision {
return &receipt, nil
}
var config map[string]json.RawMessage
if err := json.Unmarshal(desired, &config); err != nil {
return nil, err
}
var power struct {
Mode string `json:"mode"`
}
if err := json.Unmarshal(config["power"], &power); err != nil || (power.Mode != "standby" && power.Mode != "active") {
return &receipt, nil
}
if power.Mode == "standby" {
if _, err := tx.Exec(ctx, `UPDATE device_twin
SET desired_config=jsonb_set(desired_config,'{power}','{"mode":"active"}'::jsonb),
desired_revision=desired_revision+1,updated_at=now()
WHERE device_id=$1 AND tenant_id=$2`, d.ID, d.TenantID); err != nil {
return nil, err
}
}
receipt.Result = "applied"
return &receipt, nil
}
func (s *Service) DeviceControlSync(ctx context.Context, d Device, in DeviceControlReport) (map[string]any, error) {
if !deviceAllowed(d) {
return nil, errAccess
}
if err := validateControlReport(in); err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
wakeReceipt, err := reconcileScreenWake(ctx, tx, d, in.ScreenWake)
if err != nil {
return nil, err
}
// Revision zero means the device has not applied cloud configuration yet.
// Report hardware readings, never copy desired values into reported_config.
values := map[string]any{"volume": map[string]any{"speaker": in.Speaker},
"network": map[string]any{"type": "wifi", "rssi": in.RSSI}}
// Older firmware omits the field; omission must not claim a name was applied.
if in.DisplayName != nil {
values["identity"] = map[string]any{"display_name": *in.DisplayName}
}
if in.Power != nil {
values["power"] = in.Power
}
if wakeReceipt != nil {
values["screen_wake"] = wakeReceipt
}
reported, _ := json.Marshal(values)
tag, err := tx.Exec(ctx, `UPDATE device_twin SET firmware_version=$3,
reported_config=reported_config || $4::jsonb,
reported_revision=NULLIF($5,0),
last_reported_at=now(),updated_at=now()
WHERE device_id=$1 AND tenant_id=$2 AND $5<=desired_revision
AND ($5=0 OR reported_revision IS NULL OR $5>=reported_revision)`, d.ID, d.TenantID, in.Firmware, reported, in.Revision)
if err != nil {
return nil, err
}
if tag.RowsAffected() != 1 {
return nil, fail(409, "CONFIG_REVISION_CONFLICT", "配置版本已变化,请重新同步")
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
repo := device.NewRepository(s.pool)
config, err := repo.GetConfiguration(ctx, controlIdentity(d))
if err != nil {
return nil, err
}
items := []device.Command{}
if !in.Busy {
items, err = repo.PullCommands(ctx, controlIdentity(d), 1)
if err != nil {
return nil, err
}
}
out := map[string]any{"configuration": config, "commands": items}
if wakeReceipt != nil {
out["screen_wake"] = wakeReceipt
}
return out, nil
}
type DeviceControlAck struct {
ID string `json:"command_id"`
Status string `json:"status"`
Result map[string]any `json:"result_summary"`
}
func validateControlAck(in DeviceControlAck) error {
if _, err := uuid.Parse(in.ID); err != nil {
return fail(400, "INVALID_COMMAND_ACK", "命令编号不正确")
}
if in.Status != "ACKNOWLEDGED" && in.Status != "SUCCEEDED" && in.Status != "FAILED" {
return fail(400, "INVALID_COMMAND_ACK", "命令状态不正确")
}
raw, err := json.Marshal(in.Result)
if err != nil || len(raw) > 4096 {
return fail(400, "INVALID_COMMAND_ACK", "诊断结果过大或格式不正确")
}
return nil
}
func (s *Service) DeviceControlAcknowledge(ctx context.Context, d Device, in DeviceControlAck) (map[string]any, error) {
if !deviceAllowed(d) {
return nil, errAccess
}
if err := validateControlAck(in); err != nil {
return nil, err
}
if in.Result == nil {
in.Result = map[string]any{}
}
_, err := device.NewRepository(s.pool).AcknowledgeCommand(ctx, controlIdentity(d), in.ID,
device.CommandAcknowledgement{Status: in.Status, ResultSummary: in.Result})
if err != nil {
// A missing, foreign, expired, or terminal command cannot gain authority.
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
return nil, fail(409, "DEVICE_COMMAND_STATE_CONFLICT", "命令不存在、已过期或状态已变化")
}
return map[string]any{"status": "ok"}, nil
}
+239
View File
@@ -0,0 +1,239 @@
package onboarding
import (
"data-terminal-cloud/internal/device"
"github.com/google/uuid"
"strings"
"sync"
"testing"
)
func TestControlInputValidation(t *testing.T) {
valid := DeviceControlReport{Firmware: "1.7.0", Revision: 1, Speaker: 70, RSSI: -55}
if err := validateControlReport(valid); err != nil {
t.Fatal(err)
}
for _, mutate := range []func(*DeviceControlReport){
func(v *DeviceControlReport) { v.Speaker = -1 }, func(v *DeviceControlReport) { v.Speaker = 101 },
func(v *DeviceControlReport) { v.Revision = -1 }, func(v *DeviceControlReport) { v.Firmware = "" },
func(v *DeviceControlReport) { v.RSSI = -128 },
func(v *DeviceControlReport) { s := "名称\n"; v.DisplayName = &s },
func(v *DeviceControlReport) { v.Power = &DevicePowerReport{Mode: "standby", MicrophonePaused: false} },
func(v *DeviceControlReport) {
v.Power = &DevicePowerReport{Mode: "standby", MicrophonePaused: true, BacklightPercent: 80}
},
func(v *DeviceControlReport) { v.Power = &DevicePowerReport{Mode: "off"} },
func(v *DeviceControlReport) { v.Power = &DevicePowerReport{Mode: "active", BacklightPercent: 101} },
} {
invalid := valid
mutate(&invalid)
if validateControlReport(invalid) == nil {
t.Fatalf("accepted %+v", invalid)
}
}
ack := DeviceControlAck{ID: uuid.NewString(), Status: "SUCCEEDED"}
if err := validateControlAck(ack); err != nil {
t.Fatal(err)
}
ack.Status = "QUEUED"
if validateControlAck(ack) == nil {
t.Fatal("accepted state regression")
}
ack.Status = "FAILED"
ack.Result = map[string]any{"body": strings.Repeat("x", 4097)}
if validateControlAck(ack) == nil {
t.Fatal("accepted oversized result")
}
}
func TestScreenWakeValidation(t *testing.T) {
in := DeviceControlReport{Firmware: "1.7.0-controls.32", Revision: 4, Speaker: 60, RSSI: -50,
Power: &DevicePowerReport{Mode: "active", BacklightPercent: 80},
ScreenWake: &DeviceScreenWake{ID: uuid.NewString(), Revision: 4}}
must(t, validateControlReport(in))
for _, mutation := range []func(*DeviceControlReport){
func(r *DeviceControlReport) { r.ScreenWake = &DeviceScreenWake{ID: "bad", Revision: 4} },
func(r *DeviceControlReport) { r.ScreenWake = &DeviceScreenWake{ID: uuid.NewString()} },
func(r *DeviceControlReport) { r.Power = nil },
func(r *DeviceControlReport) { r.Power = &DevicePowerReport{Mode: "standby", MicrophonePaused: true} },
} {
invalid := in
mutation(&invalid)
if validateControlReport(invalid) == nil {
t.Fatal("invalid screen wake accepted")
}
}
}
func TestScreenWakeReconcilesAndFencesNewerConfiguration(t *testing.T) {
f := newFixture(t)
_, err := f.s.pool.Exec(f.ctx, `UPDATE device_twin SET desired_revision=4,reported_revision=4,
desired_config='{"volume":{"speaker":31},"identity":{"display_name":"保留名称"},"power":{"mode":"standby"}}',
reported_config='{}' WHERE device_id=$1`, f.d.ID)
must(t, err)
in := DeviceControlReport{Firmware: "1.7.0-controls.32", Revision: 4, Speaker: 31, RSSI: -50,
Power: &DevicePowerReport{Mode: "active", BacklightPercent: 80},
ScreenWake: &DeviceScreenWake{ID: uuid.NewString(), Revision: 4}}
check := func(wantRevision int64, wantMode, wantResult string) {
t.Helper()
out, err := f.s.DeviceControlSync(f.ctx, f.d, in)
must(t, err)
if receipt := out["screen_wake"].(*DeviceScreenWakeReceipt); receipt.ID != in.ScreenWake.ID || receipt.Result != wantResult {
t.Fatal(receipt)
}
var revision, reported int64
var mode, name string
var speaker int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT desired_revision,reported_revision,desired_config->'power'->>'mode',
desired_config->'identity'->>'display_name',(desired_config->'volume'->>'speaker')::int
FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&revision, &reported, &mode, &name, &speaker))
if revision != wantRevision || reported != in.Revision || mode != wantMode || name != "保留名称" || speaker != 31 {
t.Fatalf("revision=%d reported=%d mode=%s name=%s speaker=%d", revision, reported, mode, name, speaker)
}
}
check(5, "active", "applied")
check(5, "active", "applied") // Retry after a lost reply must not add a revision.
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_twin SET desired_revision=6,
desired_config=jsonb_set(desired_config,'{power}','{"mode":"standby"}') WHERE device_id=$1`, f.d.ID)
must(t, err)
check(6, "standby", "applied") // The old successful event cannot undo a later standby.
in.ScreenWake = &DeviceScreenWake{ID: uuid.NewString(), Revision: 4}
check(6, "standby", "superseded") // An offline stale tap respects the newer config.
in.ScreenWake = &DeviceScreenWake{ID: uuid.NewString(), Revision: 6}
in.Revision = 6
var wg sync.WaitGroup
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() { defer wg.Done(); _, err := f.s.DeviceControlSync(f.ctx, f.d, in); errs <- err }()
}
wg.Wait()
close(errs)
for err := range errs {
must(t, err)
}
check(7, "active", "applied")
in.ScreenWake = &DeviceScreenWake{ID: in.ScreenWake.ID, Revision: 7}
if _, err := f.s.DeviceControlSync(f.ctx, f.d, in); err == nil {
t.Fatal("receipt ID reused with a changed revision")
}
foreign := f.d
foreign.ID = uuid.NewString()
if _, err := f.s.DeviceControlSync(f.ctx, foreign, in); err == nil {
t.Fatal("foreign device altered power")
}
}
func TestControlNameReportsAreActualReadings(t *testing.T) {
f := newFixture(t)
_, err := f.s.pool.Exec(f.ctx, `UPDATE device_twin SET desired_revision=2,desired_config='{"identity":{"display_name":"云端名称"}}',reported_config='{}',reported_revision=NULL WHERE device_id=$1`, f.d.ID)
must(t, err)
in := DeviceControlReport{Firmware: "1.7.0", Speaker: 60, RSSI: -50}
_, err = f.s.DeviceControlSync(f.ctx, f.d, in)
must(t, err)
var name *string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT reported_config->'identity'->>'display_name' FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&name))
if name != nil {
t.Fatal("old firmware falsely reported desired name")
}
actual := "设备实际名称"
in.DisplayName = &actual
_, err = f.s.DeviceControlSync(f.ctx, f.d, in)
must(t, err)
must(t, f.s.pool.QueryRow(f.ctx, `SELECT reported_config->'identity'->>'display_name' FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&name))
if name == nil || *name != actual {
t.Fatal("actual name lost or replaced by desired")
}
actual = ""
_, err = f.s.DeviceControlSync(f.ctx, f.d, in)
must(t, err)
must(t, f.s.pool.QueryRow(f.ctx, `SELECT reported_config->'identity'->>'display_name' FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&name))
if name == nil || *name != "" {
t.Fatal("explicit default name not reported")
}
}
func TestControlPowerActualReadings(t *testing.T) {
f := newFixture(t)
_, err := f.s.pool.Exec(f.ctx, `UPDATE device_twin SET desired_config=desired_config || '{"power":{"mode":"standby"}}'::jsonb WHERE device_id=$1`, f.d.ID)
must(t, err)
in := DeviceControlReport{Firmware: "1.7.0", Speaker: 60, RSSI: -50}
_, err = f.s.DeviceControlSync(f.ctx, f.d, in)
must(t, err)
var mode *string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT reported_config->'power'->>'mode' FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&mode))
if mode != nil {
t.Fatal("old firmware falsely claimed standby")
}
for _, p := range []DevicePowerReport{{Mode: "active", BacklightPercent: 80, MicrophoneReads: 12}, {Mode: "standby", MicrophonePaused: true, BacklightPercent: 0, MicrophoneReads: 12}} {
in.Power = &p
_, err = f.s.DeviceControlSync(f.ctx, f.d, in)
must(t, err)
must(t, f.s.pool.QueryRow(f.ctx, `SELECT reported_config->'power'->>'mode' FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&mode))
if mode == nil || *mode != p.Mode {
t.Fatal("power report substituted by desired mode")
}
}
}
func TestDeviceControlReportsAndCommandIsolation(t *testing.T) {
f := newFixture(t)
_, err := f.s.pool.Exec(f.ctx, `UPDATE device_twin SET desired_revision=2,desired_config='{"volume":{"speaker":40}}',reported_revision=NULL WHERE device_id=$1`, f.d.ID)
must(t, err)
commandID := uuid.NewString()
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO device_command(public_id,tenant_id,device_id,command_type,requested_by,idempotency_key,request_digest,expires_at)
VALUES($1,$2,$3,'diagnostics',$4,$5,repeat('d',64),now()+interval '5 minutes')`, commandID, f.tenant, f.d.ID, f.operatorID, uuid.NewString())
must(t, err)
in := DeviceControlReport{Firmware: "1.7.0", Speaker: 70, RSSI: -50, Busy: true}
out, err := f.s.DeviceControlSync(f.ctx, f.d, in)
must(t, err)
if len(out["commands"].([]device.Command)) != 0 {
t.Fatal("delivered during a call")
}
var actual int
var revision *int64
must(t, f.s.pool.QueryRow(f.ctx, `SELECT (reported_config->'volume'->>'speaker')::int,reported_revision FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&actual, &revision))
if actual != 70 || revision != nil {
t.Fatal("cloud desired value falsely reported as applied")
}
in.Speaker = 40
in.Revision = 2
in.Busy = false
out, err = f.s.DeviceControlSync(f.ctx, f.d, in)
must(t, err)
items := out["commands"].([]device.Command)
if len(items) != 1 || items[0].ID != commandID || items[0].Status != "DELIVERED" {
t.Fatal(items)
}
in.Revision = 3
if _, err = f.s.DeviceControlSync(f.ctx, f.d, in); err == nil {
t.Fatal("accepted future revision")
}
in.Revision = 1
if _, err = f.s.DeviceControlSync(f.ctx, f.d, in); err == nil {
t.Fatal("accepted stale revision")
}
ack := DeviceControlAck{ID: commandID, Status: "ACKNOWLEDGED"}
foreign := f.d
foreign.ID = uuid.NewString()
if _, err = f.s.DeviceControlAcknowledge(f.ctx, foreign, ack); err == nil {
t.Fatal("foreign device acknowledged command")
}
_, err = f.s.DeviceControlAcknowledge(f.ctx, f.d, ack)
must(t, err)
ack.Status = "SUCCEEDED"
ack.Result = map[string]any{"speaker": 40}
_, err = f.s.DeviceControlAcknowledge(f.ctx, f.d, ack)
must(t, err)
_, err = f.s.DeviceControlAcknowledge(f.ctx, f.d, ack)
must(t, err)
ack.Status = "FAILED"
if _, err = f.s.DeviceControlAcknowledge(f.ctx, f.d, ack); err == nil {
t.Fatal("changed terminal result")
}
blocked := f.d
blocked.Status = "revoked"
if _, err = f.s.DeviceControlSync(f.ctx, blocked, in); err == nil {
t.Fatal("revoked device synced")
}
}
+66
View File
@@ -0,0 +1,66 @@
package onboarding
import (
"context"
"data-terminal-cloud/internal/devicebinding"
"data-terminal-cloud/internal/devicereset"
"data-terminal-cloud/internal/idempotency"
"errors"
)
// This is a DEVREQ1 adapter for the existing reset transaction, not an owner
// authorization shortcut. The account must release ownership before reset.
func (s *Service) DeviceResetState(ctx context.Context, d Device) (map[string]any, error) {
if !deviceAllowed(d) || d.Mode != "physical_confirm" {
return nil, errAccess
}
if err := devicebinding.RequireUnboundForReset(ctx, s.pool, d.ID); err != nil {
return nil, resetError(err)
}
var counter int64
if err := s.pool.QueryRow(ctx, `SELECT factory_reset_counter FROM device WHERE id=$1 AND tenant_id=$2`, d.ID, d.TenantID).Scan(&counter); err != nil {
return nil, err
}
return map[string]any{"reset_counter": counter, "binding_generation": d.Generation}, nil
}
func (s *Service) DeviceFactoryReset(ctx context.Context, d Device, requestID string, raw []byte, in devicereset.Input) (devicereset.Response, error) {
if !deviceAllowed(d) || d.Mode != "physical_confirm" {
return devicereset.Response{}, errAccess
}
service, err := devicereset.NewService(s.pool, idempotency.NewExecutor(s.pool), s.audit)
if err != nil {
return devicereset.Response{}, err
}
// The reset ID lives in the signed and persisted body. HTTP requests may use
// fresh nonce/request IDs while retrying an uncertain result after a reboot.
response, _, err := service.Reset(ctx, controlIdentity(d), "factory-reset:"+in.ResetID, requestID, raw, in)
return response, resetError(err)
}
func resetError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, devicebinding.ErrConfirmationRequired):
return fail(409, "DEVICE_RESET_AUTHORIZATION_REQUIRED", "请先由当前账号授权解绑,再恢复出厂")
case errors.Is(err, devicereset.ErrCallInProgress):
return fail(409, "DEVICE_RESET_CALL_IN_PROGRESS", "设备正在通话,请结束后再恢复出厂")
case errors.Is(err, devicereset.ErrFirmwareInProgress):
return fail(409, "DEVICE_RESET_FIRMWARE_IN_PROGRESS", "设备正在升级,暂时不能恢复出厂")
case errors.Is(err, devicereset.ErrPendingTranscripts):
return fail(409, "DEVICE_RESET_PENDING_TRANSCRIPTS", "设备仍有未上传的聊天记录")
case errors.Is(err, devicereset.ErrResetCounterConflict):
return fail(409, "DEVICE_RESET_COUNTER_CONFLICT", "设备重置计数器已变化")
case errors.Is(err, devicereset.ErrStateConflict):
return fail(409, "DEVICE_RESET_STATE_CONFLICT", "设备状态已变化,请重新确认")
case errors.Is(err, devicereset.ErrInvalid):
return fail(400, "INVALID_RESET_REQUEST", "设备重置参数不正确")
case errors.Is(err, idempotency.ErrKeyConflict):
return fail(409, "IDEMPOTENCY_KEY_CONFLICT", "同一重置请求不能修改内容")
case errors.Is(err, idempotency.ErrInProgress):
return fail(409, "IDEMPOTENCY_IN_PROGRESS", "设备重置请求仍在处理中")
default:
return err
}
}
+174
View File
@@ -0,0 +1,174 @@
package onboarding
import (
"bytes"
"data-terminal-cloud/internal/devicereset"
"encoding/json"
"github.com/google/uuid"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func resetInput() devicereset.Input {
return devicereset.Input{ResetID: uuid.NewString(), ResetCounter: 1, OccurredAt: time.Now().UTC(), Trigger: "device_menu", FirmwareVersion: "1.7.0"}
}
func TestFactoryResetDEVREQ1ClearsSettingsAndReplays(t *testing.T) {
f := newFixture(t)
_, err := f.s.pool.Exec(f.ctx, `UPDATE device_twin SET desired_config='{"identity":{"display_name":"旧用户"},"volume":{"speaker":12},"power":{"mode":"standby"}}',reported_config='{"volume":{"speaker":12}}',reported_revision=desired_revision WHERE device_id=$1`, f.d.ID)
must(t, err)
status, body := f.deviceCall("factory-reset/state", map[string]any{}, "", true)
if status != 200 || body["data"].(map[string]any)["reset_counter"] != float64(0) {
t.Fatalf("state %d %v", status, body)
}
in := resetInput()
status, body = f.deviceCall("factory-reset", in, "", true)
if status != 200 {
t.Fatalf("reset %d %v", status, body)
}
first, _ := json.Marshal(body["data"])
result := body["data"].(map[string]any)
if result["claim"] != nil || result["activation_state"] != "CLAIMABLE" || result["reset_counter"] != float64(1) {
t.Fatalf("result %v", result)
}
var desired, reported []byte
var revision int64
must(t, f.s.pool.QueryRow(f.ctx, `SELECT desired_config,reported_config,desired_revision FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&desired, &reported, &revision))
if string(desired) != "{}" || string(reported) != "{}" {
t.Fatal("old settings survived reset")
}
// Same durable body, fresh signed HTTP nonce/request ID after lost response.
status, body = f.deviceCall("factory-reset", in, "", true)
second, _ := json.Marshal(body["data"])
if status != 200 || !bytes.Equal(first, second) {
t.Fatalf("replay %d %v", status, body)
}
var after int64
must(t, f.s.pool.QueryRow(f.ctx, `SELECT desired_revision FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&after))
if after != revision {
t.Fatal("replay reset twice")
}
in.FirmwareVersion = "different"
status, body = f.deviceCall("factory-reset", in, "", true)
if status != 409 || body["code"] != "IDEMPOTENCY_KEY_CONFLICT" {
t.Fatalf("mutated replay %d %v", status, body)
}
}
func TestFactoryResetRejectsBothOwnershipKindsAndAnonymous(t *testing.T) {
for _, kind := range []string{"family", "account"} {
t.Run(kind, func(t *testing.T) {
f := newFixture(t)
if kind == "family" {
f.bound()
} else {
p := accountOnlyUser(t, f)
token := accountToken(t, f, p)
_, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
}
for _, path := range []string{"factory-reset/state", "factory-reset"} {
var in any = map[string]any{}
if path == "factory-reset" {
in = resetInput()
}
status, body := f.deviceCall(path, in, "", true)
if status != 409 || body["code"] != "DEVICE_RESET_AUTHORIZATION_REQUIRED" {
t.Fatalf("bound %s %d %v", path, status, body)
}
mux := http.NewServeMux()
f.s.RegisterRoutes(mux)
r := httptest.NewRequest("POST", "https://platform.example/api/v1/device/"+path, bytes.NewReader(requestJSON(in)))
r.Header.Set("X-Request-ID", uuid.NewString())
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
if w.Code != 401 {
t.Fatalf("anonymous request returned %d", w.Code)
}
}
var counter int64
must(t, f.s.pool.QueryRow(f.ctx, `SELECT factory_reset_counter FROM device WHERE id=$1`, f.d.ID).Scan(&counter))
if counter != 0 {
t.Fatal("refused reset changed device")
}
})
}
}
func TestFactoryResetRejectsFleetOTAAndDoesNotClearSettings(t *testing.T) {
f := newFixture(t)
_, err := f.s.pool.Exec(f.ctx, `UPDATE device_twin SET desired_config='{"identity":{"display_name":"保留"}}' WHERE device_id=$1`, f.d.ID)
must(t, err)
var release string
must(t, f.s.pool.QueryRow(f.ctx, `INSERT INTO fleet_firmware(tenant_id,model,version,size,sha256,object_ref,status) VALUES($1,$2,'reset-guard',32,$3,$4,'READY') RETURNING id::text`, f.tenant, f.d.Model, digest([]byte("test")), "test://"+uuid.NewString()).Scan(&release))
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO fleet_ota(device_id,release_id,request_id) VALUES($1,$2,$3)`, f.d.ID, release, uuid.NewString())
must(t, err)
for _, phase := range []string{"pending", "accepted"} {
_, err = f.s.pool.Exec(f.ctx, `UPDATE fleet_ota SET status=$2 WHERE device_id=$1`, f.d.ID, phase)
must(t, err)
status, body := f.deviceCall("factory-reset", resetInput(), "", true)
if status != 409 || body["code"] != "DEVICE_RESET_FIRMWARE_IN_PROGRESS" {
t.Fatalf("OTA %s: %d %v", phase, status, body)
}
}
var counter int64
must(t, f.s.pool.QueryRow(f.ctx, `SELECT factory_reset_counter FROM device WHERE id=$1`, f.d.ID).Scan(&counter))
if counter != 0 {
t.Fatal("OTA denial incremented reset counter")
}
var name string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT desired_config->'identity'->>'display_name' FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&name))
if name != "保留" {
t.Fatal("refused reset changed settings")
}
}
func TestFactoryResetRejectsActiveDirectCalls(t *testing.T) {
f := newFixture(t)
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO call_session(device_id,tenant_id,boot_id,hook_event_id,call_generation,binding_generation) VALUES($1,$2,$3,'reset-guard',1,0)`, f.d.ID, f.tenant, uuid.NewString())
must(t, err)
for _, phase := range []string{"PREPARING", "STARTING", "TALKING", "STOPPING"} {
_, err = f.s.pool.Exec(f.ctx, `UPDATE call_session SET call_state=$2 WHERE device_id=$1`, f.d.ID, phase)
must(t, err)
status, body := f.deviceCall("factory-reset", resetInput(), "", true)
if status != 409 || body["code"] != "DEVICE_RESET_CALL_IN_PROGRESS" {
t.Fatalf("call %s: %d %v", phase, status, body)
}
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE call_session SET call_state='ENDED' WHERE device_id=$1`, f.d.ID)
must(t, err)
status, body := f.deviceCall("factory-reset", resetInput(), "", true)
if status != 200 {
t.Fatalf("idle reset %d %v", status, body)
}
}
func TestFactoryResetOldReceiptCannotClearNewOwner(t *testing.T) {
f := newFixture(t)
in := resetInput()
status, body := f.deviceCall("factory-reset", in, "", true)
if status != 200 {
t.Fatalf("reset %d %v", status, body)
}
// Simulate the next boot's fresh authenticated device snapshot, then use
// the real account-first binding flow to establish a new owner.
d, err := f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
must(t, err)
f.d = d
p := accountOnlyUser(t, f)
token := accountToken(t, f, p)
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
status, body = f.deviceCall("factory-reset", in, "", true)
if status != 409 || body["code"] != "DEVICE_RESET_AUTHORIZATION_REQUIRED" {
t.Fatalf("old receipt %d %v", status, body)
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM device_account_binding WHERE device_id=$1 AND released_at IS NULL`, f.d.ID).Scan(&count))
if count != 1 {
t.Fatal("cached reset removed new owner")
}
}
@@ -0,0 +1,137 @@
package onboarding
import (
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/volcsubtitle"
"github.com/google/uuid"
)
// Real database + real HTTP signature/authentication + fake supplier boundary.
// The target device is newly manufactured and never calls any startup endpoint.
func TestDirectHTTPBindChatWithoutStartupRegistration(t *testing.T) {
f := serverFixture(t)
in := InventoryInput{FactoryMAC: strings.ToUpper(strings.ReplaceAll(uuid.NewString(), "-", ""))[:12], Model: "KP01", HardwareRevision: "A1", BatchNo: "direct-no-startup"}
_, err := f.s.CreateInventory(f.ctx, f.p, f.tenantPublic, uuid.NewString(), uuid.NewString(), requestJSON(in), in)
must(t, err)
f.d, err = f.s.findFactoryDevice(f.ctx, "KP01-"+in.FactoryMAC, 1)
must(t, err)
if f.d.BootID != "" {
t.Fatal("new target already has a boot registration")
}
mux := http.NewServeMux()
f.s.RegisterRoutes(mux)
chat := callmemory.NewService(f.s.pool, nil)
secret := []byte(strings.Repeat("test-callback-", 3))
chat.ConfigureDirect(callmemory.DirectOptions{CallbackURL: "https://example.com/callbacks/v1/volcengine/chat", CallbackSecret: secret})
chat.RegisterDirectRoutes(mux, f.s.AuthenticateCallRequest)
mux.HandleFunc("POST /callbacks/v1/volcengine/chat", chat.DirectCallback)
post := func(path string, body any, rid string) (int, map[string]any) {
t.Helper()
if rid == "" {
rid = uuid.NewString()
}
raw := requestJSON(body)
r := httptest.NewRequest("POST", "https://platform.example"+path, bytes.NewReader(raw))
nonce, timestamp := randomToken(16), time.Now().UnixMilli()
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", rid)
r.Header.Set("Authorization", "Device-HMAC-SHA256 "+RequestMAC(f.d.Key, "POST", path, f.d.FactoryID, 1, timestamp, nonce, rid, raw))
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
var result map[string]any
must(t, json.Unmarshal(w.Body.Bytes(), &result))
return w.Code, result
}
status, result := post("/api/v1/device/chat/start", map[string]any{"device_id": f.d.FactoryID, "session_id": uuid.NewString()}, "")
if status != 409 || result["code"] != "DEVICE_UNBOUND" {
t.Fatal("unbound start", status, result["code"])
}
token, rid := f.bleToken(), uuid.NewString()
for i := 0; i < 2; i++ {
status, result = post("/api/v1/device/ble-bind", BLEBindInput{Token: token}, rid)
if status != 200 {
t.Fatal("bind HTTP failure", status, result["code"])
}
data := result["data"].(map[string]any)
for _, field := range []string{"instance_id", "product_key", "device_name", "device_secret", "RTCAppID"} {
if data[field] == nil || data[field] == "" {
t.Fatal("missing binding credential", field)
}
}
}
if f.registrar.calls.Load() != 1 {
t.Fatal("binding retry registered supplier twice")
}
var subject, user string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT b.subject_id::text,b.bound_by::text FROM device_binding b WHERE b.device_id=$1::uuid AND b.unbound_at IS NULL`, f.d.ID).Scan(&subject, &user))
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO subject_voice_agent(subject_id,bot_name,bot_id,status) VALUES($1::uuid,$2,$2,'READY')`, subject, "flow-bot-"+subject)
must(t, err)
// Explicit test consent lets us verify the callback is persisted, not just acknowledged.
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO consent_record(tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref) SELECT $1::uuid,$2::uuid,consent_type,scope,policy_version,notice_digest,$3::uuid,evidence_ref FROM consent_record WHERE subject_id='10000000-0000-0000-0000-000000000401' AND withdrawn_at IS NULL`, f.tenant, subject, user)
must(t, err)
sid := uuid.NewString()
body := map[string]any{"device_id": f.d.FactoryID, "session_id": sid}
status, result = post("/api/v1/device/chat/start", body, "")
if status != 200 {
t.Fatal("start without startup endpoints failed", status, result["code"])
}
first := result["data"].(map[string]any)
status, result = post("/api/v1/device/chat/start", body, "")
if status != 200 || string(requestJSON(first["params"])) != string(requestJSON(result["data"].(map[string]any)["params"])) {
t.Fatal("retry/renewal changed parameters", status)
}
n := int64(1)
packet := volcsubtitle.Packet{Type: "subtitle", Data: []volcsubtitle.Segment{{UserID: "bot_20260914095023000", RoundID: &n, Sequence: &n, Text: "你好", Definite: true}}}
envelope := volcsubtitle.Envelope{Message: base64.StdEncoding.EncodeToString(requestJSON(packet)), Signature: volcsubtitle.Signature(secret, "direct:"+sid)}
for i := 0; i < 2; i++ {
r := httptest.NewRequest("POST", "/callbacks/v1/volcengine/chat?session_id="+sid, bytes.NewReader(requestJSON(envelope)))
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
if w.Code != 200 {
t.Fatal("callback failed", w.Code)
}
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM call_utterance WHERE session_id=$1::uuid`, sid).Scan(&count))
if count != 1 {
t.Fatal("callback record duplicated or missing", count)
}
end := map[string]any{"device_id": f.d.FactoryID, "session_id": sid, "reason": "hangup", "sdk_stopped": true}
for i := 0; i < 2; i++ {
status, result = post("/api/v1/device/chat/end", end, "")
if status != 200 {
t.Fatal("end failed", status, result["code"])
}
}
var lifecycle string
var noHandshake bool
must(t, f.s.pool.QueryRow(f.ctx, `SELECT d.activation_state,d.onboarding_boot_id IS NULL AND j.config_ack_at IS NULL AND j.engine_ready_at IS NULL FROM device d JOIN device_activation_job j ON j.device_id=d.id WHERE d.id=$1::uuid`, f.d.ID).Scan(&lifecycle, &noHandshake))
if lifecycle != "ACTIVE" || !noHandshake {
t.Fatal("old handshake required or fabricated", lifecycle, noHandshake)
}
_, 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)
body["session_id"] = uuid.NewString()
status, result = post("/api/v1/device/chat/start", body, "")
if status != 409 || result["code"] != "DEVICE_UNBOUND" {
t.Fatal("unbound start not rejected", status)
}
status, result = post("/api/v1/device/chat/end", end, "")
if status != 200 {
t.Fatal("end after unbind failed", status)
}
}
+167 -7
View File
@@ -1,10 +1,14 @@
package onboarding
import (
"context"
"data-terminal-cloud/internal/devicefleet"
"data-terminal-cloud/internal/devicereset"
"data-terminal-cloud/internal/identity"
"encoding/json"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"io"
"net"
@@ -16,6 +20,12 @@ import (
)
func (s *Service) cookieName(account bool) string {
if s.operatorLogin {
if s.options.AllowLocalHTTP {
return "platform-operator"
}
return "__Host-platform-operator"
}
n := "__Host-onboarding"
if account {
n = "__Host-platform-onboarding"
@@ -42,8 +52,9 @@ func (s *Service) secure(r *http.Request) bool {
}
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
if err == nil && prefix.Contains(ip) {
proto := r.Header.Get("X-Forwarded-Proto")
return proto == "https" || (s.options.AllowLocalHTTP && proto == "http")
}
}
return false
@@ -53,16 +64,56 @@ func (s *Service) sameOrigin(r *http.Request) bool {
return r.Header.Get("Origin") == entry.Scheme+"://"+entry.Host
}
func (s *Service) RegisterRoutes(mux *http.ServeMux) {
s.mount(mux, "GET /api/v1/ops/login-captcha-policy", "captcha-policy")
s.mount(mux, "POST /api/v1/ops/login-captcha-policy", "captcha-policy")
for _, name := range []string{"login/session", "login/captcha", "login/password", "login/sms/send", "login/sms/verify", "login/wechat", "login/wechat-link", "logout", "ble-session", "ble-device-state", "ble-bind-tokens", "ble-bind-status", "device-subject"} {
s.mount(mux, "POST /api/v1/mini/"+name, "mini")
}
s.mount(mux, "GET /api/v1/mini/account", "mini")
for _, name := range []string{"ble-bind-tokens", "ble-bind-status", "ble-device-state", "device-subject"} {
s.mount(mux, "POST /api/v1/web/"+name, "ble-web")
}
s.mount(mux, "POST /api/v1/device/ble-bind", "device")
s.mount(mux, "POST /api/v1/device/mqtt-config", "device")
s.mount(mux, "POST /api/v1/device/control/sync", "device")
s.mount(mux, "POST /api/v1/device/control/ack", "device")
s.mount(mux, "POST /api/v1/device/factory-reset/state", "device")
s.mount(mux, "POST /api/v1/device/factory-reset", "device")
s.mount(mux, "POST /api/v1/device/ota/report", "device")
s.mount(mux, "POST /api/v1/device/ota/manifest", "device")
s.mount(mux, "POST /api/v1/device/ble-state", "device")
s.mount(mux, "POST /api/v1/device/voice-runtime", "device")
s.mount(mux, "POST /api/v1/device/voice-config", "device")
s.mount(mux, "POST /api/v1/device/voice-credentials", "device")
for _, name := range []string{"session", "captcha", "captcha/verify", "password", "sms/send", "sms/verify"} {
method := "POST"
if name == "session" {
method = "GET"
}
s.mount(mux, method+" /api/v1/web/operator/login/"+name, "operator-login")
}
s.mount(mux, "GET /api/v1/web/operator/account/session", "operator-session")
s.mount(mux, "GET /api/v1/web/operator/account/password", "operator-password")
s.mount(mux, "POST /api/v1/web/operator/account/password", "operator-password")
s.mount(mux, "POST /api/v1/web/operator/auth/logout", "operator-logout")
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"} {
for _, name := range []string{"handoffs/exchange", "auth/captcha", "auth/captcha/verify", "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, "GET /api/v1/web/login/session", "customer-login")
s.mount(mux, "POST /api/v1/web/login/sms/send", "customer-login")
s.mount(mux, "POST /api/v1/web/login/sms/verify", "customer-login")
s.mount(mux, "POST /api/v1/web/login/captcha", "customer-login")
s.mount(mux, "POST /api/v1/web/login/captcha/verify", "customer-login")
s.mount(mux, "POST /api/v1/web/login/password", "customer-login")
s.mount(mux, "GET /api/v1/web/account/password", "account-password")
s.mount(mux, "POST /api/v1/web/account/password", "account-password")
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"} {
@@ -90,7 +141,12 @@ func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
s.write(w, id, nil, fail(400, "HTTPS_REQUIRED", "此接口必须通过HTTPS访问"))
return
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
client := "saas"
if kind == "mini" {
client = "mini_program"
}
host := s.clientIP(r)
r = r.WithContext(context.WithValue(r.Context(), captchaRequestKey{}, captchaRequestInfo{client: client, ip: host}))
if host == "" {
host = "unknown"
}
@@ -98,15 +154,35 @@ func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
s.write(w, id, nil, err)
return
}
if strings.HasSuffix(r.URL.Path, "/auth/sms/send") {
if strings.HasSuffix(r.URL.Path, "/sms/send") {
if err := s.limit(r.Context(), "sms:ip:"+host, 20, time.Hour); err != nil {
s.write(w, id, nil, err)
return
}
}
if strings.HasSuffix(r.URL.Path, "/login/password") || strings.HasSuffix(r.URL.Path, "/login/wechat") || strings.HasSuffix(r.URL.Path, "/login/wechat-link") || strings.HasSuffix(r.URL.Path, "/captcha") || strings.HasSuffix(r.URL.Path, "/captcha/verify") {
if err := s.limit(r.Context(), "password:ip:"+host, 120, 10*time.Minute); err != nil {
s.write(w, id, nil, err)
return
}
}
var data any
var err error
switch kind {
case "captcha-policy":
data, err = s.captchaPolicyHTTP(r, id)
case "mini":
data, err = s.miniHTTP(r, id)
case "ble-web":
data, err = s.bleWebHTTP(r)
case "operator-login":
data, err = s.operatorView().customerLoginHTTP(w, r, id)
case "operator-session":
data, err = s.operatorView().accountSessionHTTP(r)
case "operator-password":
data, err = s.operatorView().accountPasswordHTTP(r)
case "operator-logout":
data, err = s.operatorView().logoutHTTP(w, r)
case "device":
data, err = s.deviceHTTP(r, id)
case "web":
@@ -123,8 +199,12 @@ func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
}
case "account-session":
data, err = s.accountSessionHTTP(r)
case "account-password":
data, err = s.accountPasswordHTTP(r)
case "logout":
data, err = s.logoutHTTP(w, r)
case "customer-login":
data, err = s.customerLoginHTTP(w, r, id)
}
if r.URL.Path == "/api/v1/device/time-challenges" && err == nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -219,6 +299,63 @@ func (s *Service) deviceHTTP(r *http.Request, id string) (any, error) {
return nil, fail(415, "JSON_REQUIRED", "请使用application/json")
}
switch path {
case "factory-reset/state":
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.DeviceResetState(ctx, d) })
case "factory-reset":
var in devicereset.Input
if err := StrictJSON(raw, &in); err != nil {
return nil, err
}
return s.DeviceFactoryReset(ctx, d, id, raw, in)
case "control/sync":
return decodeCall(raw, func(in DeviceControlReport) (map[string]any, error) { return s.DeviceControlSync(ctx, d, in) })
case "control/ack":
return decodeCall(raw, func(in DeviceControlAck) (map[string]any, error) { return s.DeviceControlAcknowledge(ctx, d, in) })
case "mqtt-config", "ota/report", "ota/manifest":
if s.Fleet == nil {
return nil, fail(503, "MQTT_UNAVAILABLE", "MQTT服务未配置")
}
if !deviceAllowed(d) {
return nil, fail(409, "DEVICE_UNAVAILABLE", "设备状态不允许此操作")
}
var result map[string]any
var fleetErr error
if path == "mqtt-config" {
if err := StrictJSON(raw, &struct{}{}); err != nil {
return nil, err
}
result, fleetErr = s.Fleet.Config(ctx, d.ID, d.FactoryID, d.Key)
} else if path == "ota/manifest" {
var in struct {
TaskID string `json:"task_id"`
}
if err := StrictJSON(raw, &in); err != nil {
return nil, err
}
result, fleetErr = s.Fleet.Manifest(ctx, d.ID, in.TaskID)
} else {
var in devicefleet.Report
if err := StrictJSON(raw, &in); err != nil {
return nil, err
}
fleetErr = s.Fleet.Report(ctx, d.ID, in)
result = map[string]any{"status": "ok"}
}
var fe *devicefleet.Error
if errors.As(fleetErr, &fe) {
return nil, fail(409, fe.Code, fe.Message)
}
return result, fleetErr
case "voice-config":
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.VoiceConfig(ctx, d) })
case "voice-credentials":
return decodeCall(raw, func(in DeviceVoiceCredential) (map[string]any, error) { return s.ImportVoiceCredential(ctx, d, in) })
case "voice-runtime":
return decodeDeviceCall(raw, func(in VoiceRuntimeInput) (map[string]any, error) { return s.VoiceRuntime(ctx, d, in) })
case "ble-state":
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.DeviceBLEState(ctx, d) })
case "ble-bind":
return decodeCall(raw, func(in BLEBindInput) (map[string]any, error) { return s.BindBLE(ctx, d, id, in) })
case "register":
return decodeDeviceCall(raw, func(in RegisterInput) (map[string]any, error) { return s.Register(ctx, d, id, raw, in) })
case "setup-sessions":
@@ -253,6 +390,9 @@ func (s *Service) browserSession(r *http.Request) (WebSession, error) {
return s.webSession(r.Context(), s.pool, digest([]byte(c.Value)))
}
func (s *Service) issueCookies(w http.ResponseWriter, out BrowserCredentials) {
if out.TrustedDeviceToken != "" {
http.SetCookie(w, &http.Cookie{Name: s.trustedLoginCookieName(), Value: out.TrustedDeviceToken, Path: "/", Secure: !s.options.AllowLocalHTTP, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int(trustedLoginLifetime.Seconds())})
}
for _, item := range []struct {
value string
account bool
@@ -264,6 +404,9 @@ func (s *Service) issueCookies(w http.ResponseWriter, out BrowserCredentials) {
}
}
func (s *Service) bearer(r *http.Request) (identity.Principal, error) {
if strings.HasPrefix(r.Header.Get("Authorization"), "Mini ") {
return s.miniPrincipal(r)
}
if s.options.TokenVerifier == nil || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
return identity.Principal{}, fail(401, "LOGIN_REQUIRED", "请先登录")
}
@@ -338,7 +481,9 @@ func (s *Service) webHTTP(w http.ResponseWriter, r *http.Request, id string) (an
if err = StrictJSON(raw, &struct{}{}); err != nil {
return nil, err
}
p, e := s.bearer(r)
// Same-origin and setup CSRF were verified above. The account cookie
// supplies identity when returning from the standalone SMS login page.
p, e := s.browserPrincipal(r, false)
if e != nil {
return nil, e
}
@@ -347,6 +492,21 @@ func (s *Service) webHTTP(w http.ResponseWriter, r *http.Request, id string) (an
s.issueCookies(w, out)
}
return out, e
case "auth/captcha":
if err = StrictJSON(raw, &struct{}{}); err != nil {
return nil, err
}
return s.webCaptcha(ctx, ws)
case "auth/captcha/verify":
return decodeCall(raw, func(in SlideCaptchaVerifyInput) (map[string]any, error) {
return s.verifySlideCaptcha(ctx, ws.Hash, in, func(tx pgx.Tx) error {
d, e := readDevice(ctx, tx, ws.Device.ID, true)
if e != nil {
return e
}
return s.checkWebActor(ctx, tx, ws.Hash, d)
})
})
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":
@@ -403,7 +563,7 @@ func (s *Service) bootstrapHTTP(r *http.Request) (any, error) {
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)
p, err := s.operatorView().browserPrincipal(r, r.Method != http.MethodGet)
if err != nil {
return nil, err
}
+124
View File
@@ -0,0 +1,124 @@
package onboarding
import (
"fmt"
"strings"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/saas"
"github.com/google/uuid"
)
func TestProfileCreationRollsBackWhenMemoryConsentFails(t *testing.T) {
f := newFixture(t)
name := "test_initial_memory_" + strings.ReplaceAll(uuid.NewString(), "-", "")
// Fail the second grant for this synthetic tenant to verify that the first
// grant, the new household, profile, audit records and replay result all roll back.
_, err := f.s.pool.Exec(f.ctx, fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.tenant_id='%s'::uuid AND NEW.consent_type='personalized_memory' THEN RAISE EXCEPTION 'synthetic consent failure'; END IF; RETURN NEW; END $$;
CREATE TRIGGER %s BEFORE INSERT ON consent_record FOR EACH ROW EXECUTE FUNCTION %s();`, name, f.tenant, name, name))
must(t, err)
t.Cleanup(func() {
_, _ = f.s.pool.Exec(f.ctx, fmt.Sprintf("DROP TRIGGER IF EXISTS %s ON consent_record; DROP FUNCTION IF EXISTS %s();", name, name))
})
in := ProfileInput{HouseholdName: "授权失败家庭", Name: "授权失败老人", InitialConsents: saas.InitialConsentSelection{Types: []string{saas.ConsentTranscriptStorage, saas.ConsentPersonalMemory}, Confirmed: true}}
requestID := uuid.NewString()
_, err = f.s.CreateProfile(f.ctx, f.web, requestID, requestJSON(in), in)
if err == nil {
t.Fatal("profile creation succeeded despite failed consent")
}
var profiles, households, audits int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT
(SELECT count(*) FROM subject_profile WHERE tenant_id=$1::uuid AND preferred_name=$2),
(SELECT count(*) FROM household WHERE tenant_id=$1::uuid AND name=$3),
(SELECT count(*) FROM audit_event WHERE request_id=$4)`, f.tenant, in.Name, in.HouseholdName, requestID).Scan(&profiles, &households, &audits))
if profiles != 0 || households != 0 || audits != 0 {
t.Fatalf("partial creation survived: profiles=%d households=%d audits=%d", profiles, households, audits)
}
_, err = f.s.pool.Exec(f.ctx, fmt.Sprintf("DROP TRIGGER %s ON consent_record; DROP FUNCTION %s();", name, name))
must(t, err)
_, err = f.s.CreateProfile(f.ctx, f.web, requestID, requestJSON(in), in)
must(t, err)
}
func TestProfileCreationIncludesMemoryConsentAndReplay(t *testing.T) {
f := newFixture(t)
in := ProfileInput{HouseholdName: "选项授权测试家庭", Name: "选项授权测试老人", InitialConsents: saas.InitialConsentSelection{Types: []string{saas.ConsentTranscriptStorage, saas.ConsentPersonalMemory}, Confirmed: true}}
requestID := uuid.NewString()
created, err := f.s.CreateProfile(f.ctx, f.web, requestID, requestJSON(in), in)
must(t, err)
replayed, err := f.s.CreateProfile(f.ctx, f.web, requestID, requestJSON(in), in)
must(t, err)
if replayed["elder_id"] != created["elder_id"] {
t.Fatal("profile replay created another elder")
}
var count, audits int
var enabled bool
must(t, f.s.pool.QueryRow(f.ctx, `SELECT
(SELECT count(*) FROM consent_record c WHERE c.subject_id=sp.id),
(SELECT count(*) FROM audit_event WHERE request_id=$2 AND action='consent.grant'),
(SELECT effective_consents @> '{"voice_chat":true,"transcript_storage":true,"personalized_memory":true,"raw_audio_storage":false,"story_extraction":false,"family_sharing":false}'::jsonb AND jsonb_array_length(source_consent_ids)=2 FROM consent_snapshot WHERE subject_id=sp.id ORDER BY snapshot_version DESC LIMIT 1)
FROM subject_profile sp WHERE sp.public_id=$1::uuid`, created["elder_id"], requestID).Scan(&count, &audits, &enabled))
if count != 2 || audits != 2 || !enabled {
t.Fatalf("default consents count=%d audits=%d enabled=%v", count, audits, enabled)
}
}
func TestMemoryWithdrawalSurvivesDeviceBinding(t *testing.T) {
f := newFixture(t)
api := saas.NewService(f.s.pool, audit.NewRepository(), nil, nil)
consents, err := api.ListConsents(f.ctx, f.user, f.elder)
must(t, err)
for _, c := range consents {
_, err = api.WithdrawConsent(f.ctx, f.user, f.elder, c.ID, uuid.NewString())
must(t, err)
}
f.bound()
var enabled bool
must(t, f.s.pool.QueryRow(f.ctx, `SELECT call_memory_authorized(id) FROM subject_profile WHERE public_id=$1::uuid`, f.elder).Scan(&enabled))
if enabled {
t.Fatal("device binding restored withdrawn memory consent")
}
}
func TestCreationGrantsOnlyExplicitlySelectedPermissions(t *testing.T) {
f := newFixture(t)
for _, tc := range []struct {
name string
selected []string
}{
{"legacy", nil},
{"subtitle_only", []string{saas.ConsentTranscriptStorage}},
{"all", []string{saas.ConsentTranscriptStorage, saas.ConsentRawAudioStorage, saas.ConsentStoryExtraction, saas.ConsentPersonalMemory, saas.ConsentFamilySharing}},
} {
t.Run(tc.name, func(t *testing.T) {
in := ProfileInput{HouseholdName: "授权选择测试" + tc.name, Name: "测试老人", InitialConsents: saas.InitialConsentSelection{Types: tc.selected, Confirmed: len(tc.selected) > 0}}
created, err := f.s.CreateProfile(f.ctx, f.web, uuid.NewString(), requestJSON(in), in)
must(t, err)
var kinds []string
var sourceCount int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT ARRAY(SELECT consent_type FROM consent_record WHERE subject_id=sp.id ORDER BY consent_type), (SELECT jsonb_array_length(source_consent_ids) FROM consent_snapshot WHERE subject_id=sp.id ORDER BY snapshot_version DESC LIMIT 1) FROM subject_profile sp WHERE public_id=$1::uuid`, created["elder_id"]).Scan(&kinds, &sourceCount))
if len(kinds) != len(tc.selected) || sourceCount != len(tc.selected) {
t.Fatalf("grants=%v sourceCount=%d wanted=%v", kinds, sourceCount, tc.selected)
}
for _, kind := range kinds {
found := false
for _, selected := range tc.selected {
found = found || kind == selected
}
if !found {
t.Fatalf("unselected permission granted: %s", kind)
}
}
})
}
bad := ProfileInput{HouseholdName: "未确认家庭", Name: "未确认老人", InitialConsents: saas.InitialConsentSelection{Types: []string{saas.ConsentPersonalMemory}}}
if _, err := f.s.CreateProfile(f.ctx, f.web, uuid.NewString(), requestJSON(bad), bad); err == nil {
t.Fatal("unconfirmed consent was accepted")
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM household WHERE tenant_id=$1::uuid AND name=$2`, f.tenant, bad.HouseholdName).Scan(&count))
if count != 0 {
t.Fatal("unconfirmed selection left an orphan household")
}
}
+11 -13
View File
@@ -3,13 +3,9 @@ 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"
@@ -24,6 +20,7 @@ import (
"data-terminal-cloud/internal/hardwareprovision"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/saas"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
@@ -83,6 +80,7 @@ func newFixture(t *testing.T) *fixture {
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)
f.s.options.LegacyDeviceRegistration = true // Existing tests exercise old-device migration contracts.
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)
@@ -99,14 +97,14 @@ func newFixture(t *testing.T) *fixture {
}
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)
if inv["released"] != true {
t.Fatal("new inventory did not become immediately claimable")
}
var lifecycle, keyStatus, jobStatus string
must(t, f.s.pool.QueryRow(ctx, `SELECT d.activation_state,k.status,ej.status FROM device d JOIN device_onboarding_key k ON k.device_id=d.id AND k.key_version=1 JOIN device_enrollment_job ej ON ej.device_id=d.id AND ej.id=$2::uuid WHERE d.public_id=$1::uuid`, public, job).Scan(&lifecycle, &keyStatus, &jobStatus))
if lifecycle != "CLAIMABLE" || keyStatus != "ACTIVE" || jobStatus != "RELEASED" {
t.Fatalf("direct inventory state lifecycle=%s key=%s job=%s", lifecycle, keyStatus, jobStatus)
}
f.d, err = f.s.findFactoryDevice(ctx, "KP01-"+mac, 1)
must(t, err)
boot := uuid.NewString()
@@ -144,7 +142,7 @@ func newFixture(t *testing.T) *fixture {
must(t, err)
f.web, err = f.s.webSession(ctx, pool, digest([]byte(credentials.Token)))
must(t, err)
profile := ProfileInput{HouseholdName: "测试家庭", Name: "奶奶"}
profile := ProfileInput{HouseholdName: "测试家庭", Name: "奶奶", InitialConsents: saas.InitialConsentSelection{Types: []string{saas.ConsentTranscriptStorage, saas.ConsentPersonalMemory}, Confirmed: true}}
result, err := f.s.CreateProfile(ctx, f.web, uuid.NewString(), requestJSON(profile), profile)
must(t, err)
f.elder = result["elder_id"].(string)
+56 -4
View File
@@ -78,6 +78,10 @@ func (s *Service) CreateInventory(ctx context.Context, principal identity.Princi
if e != nil {
return nil, e
}
d, e = s.makeInventoryClaimable(ctx, tx, d, jobID, requestID)
if e != nil {
return nil, e
}
return inventoryResult(d, jobID), tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
@@ -94,17 +98,22 @@ func (s *Service) CreateInventory(ctx context.Context, principal identity.Princi
if err != nil {
return nil, err
}
// A repair reuses the latest credential job. It never generates a key.
// Re-submitting an existing device also upgrades records created by the
// former delivery/release flow into the direct waiting-for-binding flow.
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
}
d, err = s.makeInventoryClaimable(ctx, tx, d, jobID, requestID)
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"}
d = Device{ID: uuid.NewString(), PublicID: uuid.NewString(), TenantID: a.TenantID, FactoryID: "KP01-" + input.FactoryMAC, Model: input.Model, HardwareRevision: input.HardwareRevision, Mode: "physical_confirm", Lifecycle: string(deviceactivation.Factory)}
_, 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
@@ -113,11 +122,15 @@ func (s *Service) CreateInventory(ctx context.Context, principal identity.Princi
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)))
_, err = tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status,activated_at) VALUES($1::uuid,$2::uuid,1,$3,'ACTIVE',now())`, 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)
err = tx.QueryRow(ctx, `INSERT INTO device_enrollment_job(device_id,tenant_id,actor_id,request_key,request_digest,batch_no,status,evidence_ref) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,'RELEASED','direct-create') RETURNING id::text`, d.ID, d.TenantID, a.ID, idempotencyKey, digest(raw), input.BatchNo).Scan(&jobID)
if err != nil {
return nil, err
}
d, err = s.makeInventoryClaimable(ctx, tx, d, jobID, requestID)
if err != nil {
return nil, err
}
@@ -126,6 +139,45 @@ func (s *Service) CreateInventory(ctx context.Context, principal identity.Princi
}
return inventoryResult(d, jobID), tx.Commit(ctx)
}
// makeInventoryClaimable finishes the internal credential setup in the same
// transaction as device creation. Operators no longer need to exchange an RSA
// envelope and perform a separate release before a device can wait for binding.
func (s *Service) makeInventoryClaimable(ctx context.Context, tx pgx.Tx, d Device, jobID, requestID string) (Device, error) {
if d.Released {
return d, nil
}
var version int64
var keyStatus string
if err := tx.QueryRow(ctx, `SELECT ej.key_version,k.status FROM device_enrollment_job ej JOIN device_onboarding_key k ON k.device_id=ej.device_id AND k.key_version=ej.key_version WHERE ej.id=$1::uuid AND ej.device_id=$2::uuid FOR UPDATE OF ej,k`, jobID, d.ID).Scan(&version, &keyStatus); err != nil {
return d, err
}
if keyStatus == "PENDING" {
if _, err := tx.Exec(ctx, `UPDATE device_onboarding_key SET status='REVOKED' WHERE device_id=$1::uuid AND status='ACTIVE' AND key_version<>$2`, d.ID, version); err != nil {
return d, err
}
if _, err := tx.Exec(ctx, `UPDATE device_onboarding_key SET status='ACTIVE',activated_at=COALESCE(activated_at,now()) WHERE device_id=$1::uuid AND key_version=$2 AND status='PENDING'`, d.ID, version); err != nil {
return d, err
}
} else if keyStatus != "ACTIVE" {
return d, fail(409, "INVENTORY_CREDENTIAL_CLOSED", "设备凭据已经关闭,不能直接创建")
}
if d.Lifecycle == string(deviceactivation.Factory) {
if err := requireLifecycle(ctx, tx, d, deviceactivation.Claimable, requestID); err != nil {
return d, err
}
}
if _, 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`, d.ID); err != nil {
return d, err
}
if _, err := tx.Exec(ctx, `UPDATE device_enrollment_job SET status='RELEASED',evidence_ref=COALESCE(evidence_ref,'direct-create'),delivery_ciphertext=NULL,delivery_expires_at=NULL WHERE id=$1::uuid`, jobID); err != nil {
return d, err
}
d.Released = true
d.Lifecycle = string(deviceactivation.Claimable)
d.KeyVersion = version
return d, nil
}
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}
}
@@ -0,0 +1,95 @@
package onboarding
import (
"bytes"
"encoding/base64"
"encoding/json"
"image/png"
"strings"
"testing"
"github.com/google/uuid"
)
// Generate the real PNG and JSON record, then replace only the answer digest in
// disposable test Redis. This makes verification deterministic without solving
// a live CAPTCHA or adding an HTTP bypass.
func (f *loginFixture) knownGeneratedImageCaptcha() string {
f.t.Helper()
w, challenge := f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
if w.Code != 200 || challenge["mode"] != "image" || challenge["captcha_app_id"] != nil {
f.t.Fatal("local image challenge unavailable")
}
encoded, ok := challenge["image"].(string)
if !ok || !strings.HasPrefix(encoded, "data:image/png;base64,") {
f.t.Fatal("local image payload missing")
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(encoded, "data:image/png;base64,"))
must(f.t, err)
image, err := png.Decode(bytes.NewReader(raw))
must(f.t, err)
if image.Bounds().Dx() != 240 || image.Bounds().Dy() != 80 || strings.Contains(w.Body.String(), "answer_hash") {
f.t.Fatal("image dimensions or response confidentiality changed")
}
hash := digest([]byte(f.cookie.Value))
key := f.s.captchaKey(hash)
value, err := f.s.redis.Get(f.ctx, key).Result()
must(f.t, err)
var stored storedCaptcha
must(f.t, json.Unmarshal([]byte(value), &stored))
if stored.ID != challenge["captcha_id"] || stored.Mode != "image" || stored.Client != "saas" || stored.Version < 1 || stored.AnswerHash == "" {
f.t.Fatal("generated JSON challenge metadata missing")
}
ttl, err := f.s.redis.PTTL(f.ctx, key).Result()
must(f.t, err)
if ttl <= 0 {
f.t.Fatal("generated challenge expiry missing")
}
stored.AnswerHash = domainMAC(f.s.crypt.hashKey, "CAPTCHA1\n"+hash+"\n"+stored.ID+"\n654321")
must(f.t, f.s.redis.Set(f.ctx, key, captchaJSON(stored), ttl).Err())
return stored.ID
}
func TestLocalImageCaptchaJSONSuccessAndOneTime(t *testing.T) {
f := newLoginFixture(t)
setCaptchaTestPolicy(t, f.fixture, "image", "image")
f.s.options.Captcha = nil
id := f.knownGeneratedImageCaptcha()
hash := digest([]byte(f.cookie.Value))
must(t, f.s.verifyLoginCaptcha(f.ctx, hash, id, "654321"))
if f.s.verifyLoginCaptcha(f.ctx, hash, id, "654321") == nil {
t.Fatal("generated image challenge replay accepted")
}
}
func TestLocalImageCaptchaJSONPolicyChangeInvalidates(t *testing.T) {
f := newLoginFixture(t)
setCaptchaTestPolicy(t, f.fixture, "image", "image")
id := f.knownGeneratedImageCaptcha()
setCaptchaTestPolicy(t, f.fixture, "tencent", "image")
if f.s.verifyLoginCaptcha(f.ctx, digest([]byte(f.cookie.Value)), id, "654321") == nil {
t.Fatal("old image JSON challenge survived a policy change")
}
}
func TestLocalImageCaptchaJSONSMSDeliveryDoesNotUseTencent(t *testing.T) {
f := newLoginFixture(t)
setCaptchaTestPolicy(t, f.fixture, "image", "image")
provider := &fakeCaptchaProvider{}
f.s.options.Captcha = provider
id := f.knownGeneratedImageCaptcha()
input := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: id, CaptchaCode: "654321"}
request := uuid.NewString()
w, _ := f.request("POST", "/api/v1/web/login/sms/send", input, f.cookie, f.csrf, request)
if w.Code != 200 || f.sender.calls != 1 || provider.calls != 0 {
t.Fatal("local JSON image verification did not deliver one test SMS independently")
}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", input, f.cookie, f.csrf, request)
if w.Code != 200 || f.sender.calls != 1 || provider.calls != 0 {
t.Fatal("exact SMS retry changed delivery or used Tencent CAPTCHA")
}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", input, f.cookie, f.csrf, "")
if w.Code != 401 || f.sender.calls != 1 || provider.calls != 0 {
t.Fatal("generated image challenge replay caused another delivery")
}
}
@@ -0,0 +1,25 @@
package onboarding
import (
"context"
"errors"
)
// ProvisionLocalDemoPhone links only the checked-in synthetic owner fixture.
// It has no HTTP route and never replaces an existing password.
func (s *Service) ProvisionLocalDemoPhone(ctx context.Context, password string) error {
if !s.options.AllowLocalHTTP || !validNewPassword(password) {
return errors.New("local demo provisioning requires local HTTP and a valid password")
}
var issuer, subject string
err := s.pool.QueryRow(ctx, `SELECT oidc_issuer,oidc_subject FROM user_account WHERE id='10000000-0000-0000-0000-000000000101' AND oidc_issuer='http://localhost:58180/realms/voice-local' AND oidc_subject='00000000-0000-0000-0000-000000000101' AND account_kind='customer' AND platform_role='customer' AND status='active'`).Scan(&issuer, &subject)
if err != nil {
return err
}
encoded, err := hashPassword(ctx, password)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `INSERT INTO customer_phone_credential(phone_digest,phone_masked,issuer,subject,password_hash) VALUES($1,'+86138****0000',$2,$3,$4) ON CONFLICT DO NOTHING`, domainMAC(s.crypt.hashKey, "PHONE1\n+8613800000000"), issuer, subject, encoded)
return err
}
+31
View File
@@ -0,0 +1,31 @@
package onboarding
import (
"net/http/httptest"
"testing"
)
func TestBrowserTransportThroughConfiguredLocalProxy(t *testing.T) {
for _, tc := range []struct {
name, remote, proto string
local, want bool
}{
{"local trusted HTTP", "172.18.0.4:4000", "http", true, true},
{"production rejects HTTP", "172.18.0.4:4000", "http", false, false},
{"trusted HTTPS", "172.18.0.4:4000", "https", false, true},
{"untrusted HTTP", "172.19.0.4:4000", "http", true, false},
{"untrusted HTTPS header", "172.19.0.4:4000", "https", true, false},
{"missing proxy header", "172.18.0.4:4000", "", true, false},
{"local loopback", "127.0.0.1:4000", "", true, true},
} {
t.Run(tc.name, func(t *testing.T) {
s := &Service{options: Options{AllowLocalHTTP: tc.local, TrustedProxyCIDRs: []string{"172.18.0.0/16"}}}
r := httptest.NewRequest("GET", "http://localhost:3100/api/v1/web/login/session", nil)
r.RemoteAddr = tc.remote
r.Header.Set("X-Forwarded-Proto", tc.proto)
if got := s.secure(r); got != tc.want {
t.Fatalf("secure=%v, want %v", got, tc.want)
}
})
}
}
+299
View File
@@ -0,0 +1,299 @@
package onboarding
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"image"
"image/jpeg"
"image/png"
"math/big"
"strconv"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/redis/go-redis/v9"
background1 "github.com/wenlng/go-captcha-assets/bindata/images/image_v2_1"
background3 "github.com/wenlng/go-captcha-assets/bindata/images/image_v2_3"
background5 "github.com/wenlng/go-captcha-assets/bindata/images/image_v2_5"
"github.com/wenlng/go-captcha-assets/helper"
"github.com/wenlng/go-captcha-assets/resources/tiles"
"github.com/wenlng/go-captcha/v2/slide"
"golang.org/x/image/draw"
)
const slideWidth, slideHeight, slideTileSize, slidePadding = 320, 180, 52, 3
// Upstream v2.0.5 Generate uses math/rand for target coordinates. Use its public
// drawing API with crypto/rand coordinates instead. Each render owns its draw
// instance; decoded Apache-2.0 assets are immutable and loaded only once.
var slideResources struct {
once sync.Once
backgrounds []image.Image
graphs []*tiles.GraphImage
err error
}
var slideWorkers = make(chan struct{}, 2)
func secureSlideInt(min, max int) (int, error) {
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
if err != nil {
return 0, err
}
return min + int(n.Int64()), nil
}
type slideSolution struct{ X, Y int }
// Put two identical decoy gaps on the same track. Only the actual target supplies
// the tile pixels; all positions use crypto/rand and gaps cannot overlap.
func slideGapPositions(target slideSolution) ([]int, error) {
positions := []int{target.X}
for len(positions) < 3 {
var choices []int
for x := 10; x <= slideWidth-slideTileSize-10; x++ {
valid := true
for _, existing := range positions {
if x-existing < slideTileSize+10 && existing-x < slideTileSize+10 {
valid = false
break
}
}
if valid {
choices = append(choices, x)
}
}
if len(choices) == 0 {
return nil, errors.New("no slide decoy position available")
}
index, err := secureSlideInt(0, len(choices)-1)
if err != nil {
return nil, err
}
positions = append(positions, choices[index])
}
return positions, nil
}
func renderSlideCaptcha() (map[string]any, slideSolution, error) {
select {
case slideWorkers <- struct{}{}:
defer func() { <-slideWorkers }()
default:
return nil, slideSolution{}, fail(503, "CAPTCHA_BUSY", "验证服务繁忙,请稍后重试")
}
slideResources.once.Do(func() {
loaders := []struct {
load func(string) ([]byte, error)
path string
}{
{background1.Asset, "sourcedata/images/image-v2-1/image.jpg"},
{background3.Asset, "sourcedata/images/image-v2-3/image.jpg"},
{background5.Asset, "sourcedata/images/image-v2-5/image.jpg"},
}
for _, loader := range loaders {
raw, err := loader.load(loader.path)
if err != nil {
slideResources.err = err
return
}
img, err := helper.DecodeByteToJpeg(raw)
if err != nil {
slideResources.err = err
return
}
// Exact-size backgrounds avoid upstream's non-cryptographic crop RNG.
resized := image.NewNRGBA(image.Rect(0, 0, slideWidth, slideHeight))
draw.ApproxBiLinear.Scale(resized, resized.Bounds(), img, img.Bounds(), draw.Src, nil)
slideResources.backgrounds = append(slideResources.backgrounds, resized)
}
slideResources.graphs, slideResources.err = tiles.GetTiles()
})
if slideResources.err != nil {
return nil, slideSolution{}, slideResources.err
}
bg, err := secureSlideInt(0, len(slideResources.backgrounds)-1)
if err != nil {
return nil, slideSolution{}, err
}
graph, err := secureSlideInt(0, len(slideResources.graphs)-1)
if err != nil {
return nil, slideSolution{}, err
}
x, err := secureSlideInt(80, slideWidth-slideTileSize-10)
if err != nil {
return nil, slideSolution{}, err
}
y, err := secureSlideInt(10, slideHeight-slideTileSize-10)
if err != nil {
return nil, slideSolution{}, err
}
g := slideResources.graphs[graph]
positions, err := slideGapPositions(slideSolution{X: x, Y: y})
if err != nil {
return nil, slideSolution{}, err
}
blocks := make([]*slide.DrawBlock, 0, len(positions))
for _, gapX := range positions {
blocks = append(blocks, &slide.DrawBlock{X: gapX, Y: y, Width: slideTileSize, Height: slideTileSize, Image: g.ShadowImage})
}
d := slide.NewDrawImage()
master, bgImage, err := d.DrawWithNRGBA(&slide.DrawImageParams{
Width: slideWidth, Height: slideHeight, Background: slideResources.backgrounds[bg],
CaptchaDrawBlocks: blocks,
})
if err != nil {
return nil, slideSolution{}, err
}
tile, err := d.DrawWithTemplate(&slide.DrawTplImageParams{
Width: slideTileSize, Height: slideTileSize, Background: bgImage, MaskImage: g.MaskImage,
CaptchaDrawBlock: &slide.DrawBlock{X: x, Y: y, Width: slideTileSize, Height: slideTileSize, Image: g.OverlayImage},
})
if err != nil {
return nil, slideSolution{}, err
}
var mainBuf, tileBuf bytes.Buffer
if err = jpeg.Encode(&mainBuf, master, &jpeg.Options{Quality: 85}); err != nil {
return nil, slideSolution{}, err
}
if err = png.Encode(&tileBuf, tile); err != nil {
return nil, slideSolution{}, err
}
return map[string]any{
"mode": "slide", "image": "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(mainBuf.Bytes()),
"thumb_image": "data:image/png;base64," + base64.StdEncoding.EncodeToString(tileBuf.Bytes()),
"image_width": slideWidth, "image_height": slideHeight, "thumb_width": slideTileSize, "thumb_height": slideTileSize,
"thumb_x": 5, "thumb_y": y,
}, slideSolution{X: x, Y: y}, nil
}
func slideAAD(sessionHash string, stored storedCaptcha) string {
return "SLIDE-SOLUTION1\n" + sessionHash + "\n" + stored.ID + "\n" + stored.Client + "\n" + strconv.FormatInt(stored.Version, 10)
}
func (s *Service) slideProofHash(sessionHash, id, ticket string) string {
return domainMAC(s.crypt.hashKey, "SLIDE-PROOF1\n"+sessionHash+"\n"+id+"\n"+ticket)
}
func (s *Service) newSlideCaptcha(ctx context.Context, sessionHash string, stored *storedCaptcha) (map[string]any, error) {
if stored.Client != "saas" {
return nil, captchaInvalid()
}
out, solution, err := renderSlideCaptcha()
if err != nil {
return nil, err
}
raw, _ := json.Marshal(solution)
stored.Solution = s.crypt.seal(raw, slideAAD(sessionHash, *stored))
stored.ExpiresAt = s.now().Add(2 * time.Minute).UnixMilli()
if err = s.redis.Set(ctx, s.captchaKey(sessionHash), captchaJSON(*stored), 2*time.Minute).Err(); err != nil {
return nil, captchaUnavailable()
}
out["captcha_id"], out["expires_at_ms"] = stored.ID, stored.ExpiresAt
return out, nil
}
// Compare the entire old record so a delayed answer/proof never overwrites or
// deletes a newly refreshed challenge. Preserve Redis's original deadline.
const replaceSlideRecord = `if redis.call('GET',KEYS[1])~=ARGV[1] then return 0 end
local ttl=redis.call('PTTL',KEYS[1]); if ttl<=0 then return 0 end
redis.call('SET',KEYS[1],ARGV[2],'PX',ttl,'XX'); return 1`
const deleteCaptchaRecord = `if redis.call('GET',KEYS[1])~=ARGV[1] then return 0 end
return redis.call('DEL',KEYS[1])`
func (s *Service) consumeCaptcha(ctx context.Context, sessionHash, id string) (string, error) {
value, err := s.redis.Get(ctx, s.captchaKey(sessionHash)).Result()
if err != nil {
return "", err
}
var stored storedCaptcha
if json.Unmarshal([]byte(value), &stored) == nil {
if stored.ID != id {
return "", redis.Nil
}
} else if !strings.HasPrefix(value, id+":") {
return "", redis.Nil
}
removed, err := s.redis.Eval(ctx, deleteCaptchaRecord, []string{s.captchaKey(sessionHash)}, value).Int64()
if err != nil {
return "", err
}
if removed != 1 {
return "", redis.Nil
}
return value, nil
}
type SlideCaptchaVerifyInput struct {
ID string `json:"captcha_id"`
X *int `json:"x"`
Y *int `json:"y"`
}
func (s *Service) verifySlideCaptcha(ctx context.Context, sessionHash string, in SlideCaptchaVerifyInput, check func(pgx.Tx) error) (map[string]any, error) {
if !uuidV4(in.ID) || in.X == nil || in.Y == nil {
return nil, fail(400, "VALIDATION_FAILED", "请提交完整的拼图位置")
}
if err := s.limit(ctx, "captcha-verify:session:"+sessionHash, 30, 10*time.Minute); err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err = check(tx); err != nil {
return nil, err
}
policy, err := s.readCaptchaPolicy(ctx, tx)
if err != nil {
return nil, captchaUnavailable()
}
client := captchaRequest(ctx).client
if client != "saas" || policy.mode(client) != "slide" {
return nil, captchaInvalid()
}
value, err := s.redis.Get(ctx, s.captchaKey(sessionHash)).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, captchaInvalid()
}
return nil, captchaUnavailable()
}
var stored storedCaptcha
if json.Unmarshal([]byte(value), &stored) != nil || stored.ID != in.ID || stored.Client != client || stored.Mode != "slide" || stored.Version != policy.Version || stored.ExpiresAt <= s.now().UnixMilli() || len(stored.Solution) == 0 || stored.ProofHash != "" {
return nil, captchaInvalid()
}
plain, err := s.crypt.open(stored.Solution, slideAAD(sessionHash, stored))
if err != nil {
return nil, captchaInvalid()
}
var solution slideSolution
if json.Unmarshal(plain, &solution) != nil {
return nil, captchaInvalid()
}
if *in.X < 0 || *in.Y < 0 || *in.X > slideWidth-slideTileSize || *in.Y > slideHeight-slideTileSize || !slide.Validate(*in.X, *in.Y, solution.X, solution.Y, slidePadding) {
if err = s.redis.Eval(ctx, deleteCaptchaRecord, []string{s.captchaKey(sessionHash)}, value).Err(); err != nil {
return nil, captchaUnavailable()
}
return nil, captchaInvalid()
}
ticket := randomToken(32)
stored.Solution = nil
stored.ProofHash = s.slideProofHash(sessionHash, stored.ID, ticket)
replaced, err := s.redis.Eval(ctx, replaceSlideRecord, []string{s.captchaKey(sessionHash)}, value, captchaJSON(stored)).Int64()
if err != nil {
return nil, captchaUnavailable()
}
if replaced != 1 {
return nil, captchaInvalid()
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
return map[string]any{"captcha_id": stored.ID, "captcha_ticket": ticket, "expires_at_ms": stored.ExpiresAt}, nil
}
@@ -0,0 +1,425 @@
package onboarding
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"image"
_ "image/jpeg"
_ "image/png"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func TestLocalSlideRenderAndBounds(t *testing.T) {
for i := 0; i < 8; i++ {
out, target, err := renderSlideCaptcha()
must(t, err)
if out["mode"] != "slide" || out["thumb_x"] != 5 || out["thumb_y"] != target.Y || target.X < 80 || target.X > 258 {
t.Fatal("invalid slide geometry")
}
for _, key := range []string{"x", "y", "target_x", "target_y", "solution", "answer_hash"} {
if _, exists := out[key]; exists {
t.Fatal("solution exposed")
}
}
for _, part := range []struct {
key, prefix string
w, h int
}{
{"image", "data:image/jpeg;base64,", slideWidth, slideHeight},
{"thumb_image", "data:image/png;base64,", slideTileSize, slideTileSize},
} {
encoded := out[part.key].(string)
if !strings.HasPrefix(encoded, part.prefix) {
t.Fatal("unexpected image format")
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(encoded, part.prefix))
must(t, err)
img, _, err := image.Decode(bytes.NewReader(raw))
must(t, err)
if img.Bounds().Dx() != part.w || img.Bounds().Dy() != part.h {
t.Fatal("incorrect image bounds")
}
if len(raw) > 200000 {
t.Fatal("unbounded captcha payload")
}
}
}
}
func TestLocalSlideParallelRenderer(t *testing.T) {
var wg sync.WaitGroup
var rendered atomic.Int32
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
out, _, err := renderSlideCaptcha()
if err == nil {
if out["mode"] != "slide" {
t.Error("mode")
}
rendered.Add(1)
return
}
if e, ok := err.(*Error); !ok || e.Code != "CAPTCHA_BUSY" {
t.Error(err)
}
}()
}
wg.Wait()
if rendered.Load() < 1 {
t.Fatal("no render completed")
}
}
func TestLocalSlideDecoyGeometry(t *testing.T) {
for x := 80; x <= 258; x++ {
positions, err := slideGapPositions(slideSolution{X: x, Y: 20})
must(t, err)
if len(positions) != 3 || positions[0] != x {
t.Fatal("target lost")
}
for i, gap := range positions {
if gap < 10 || gap > 258 {
t.Fatal("gap outside image")
}
for _, other := range positions[:i] {
if gap-other < slideTileSize+10 && other-gap < slideTileSize+10 {
t.Fatal("overlapping gaps")
}
}
}
}
}
func newSlideLoginFixture(t *testing.T) *loginFixture {
f := newLoginFixture(t)
setCaptchaTestPolicy(t, f.fixture, "slide", "image")
return f
}
func (f *loginFixture) slideChallenge() (map[string]any, slideSolution) {
f.t.Helper()
w, ch := f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
if w.Code != 200 || ch["mode"] != "slide" {
f.t.Fatal("slide generation failed", w.Body.String())
}
value, err := f.s.redis.Get(f.ctx, f.s.captchaKey(digest([]byte(f.cookie.Value)))).Result()
must(f.t, err)
var stored storedCaptcha
must(f.t, json.Unmarshal([]byte(value), &stored))
plain, err := f.s.crypt.open(stored.Solution, slideAAD(digest([]byte(f.cookie.Value)), stored))
must(f.t, err)
var target slideSolution
must(f.t, json.Unmarshal(plain, &target))
return ch, target
}
func slideInput(ch map[string]any, target slideSolution) map[string]any {
return map[string]any{"captcha_id": ch["captcha_id"], "x": target.X, "y": target.Y}
}
func TestLocalSlideSuccessTicketSMSAndReplay(t *testing.T) {
f := newSlideLoginFixture(t)
provider := &fakeCaptchaProvider{}
f.s.options.Captcha = provider
ch, target := f.slideChallenge()
w, proof := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 || proof["captcha_id"] != ch["captcha_id"] || proof["expires_at_ms"] != ch["expires_at_ms"] {
t.Fatal("valid position rejected or lifetime extended", w.Body.String())
}
ticket := proof["captcha_ticket"].(string)
if !validB64(ticket, 32) {
t.Fatal("insecure proof")
}
value, err := f.s.redis.Get(f.ctx, f.s.captchaKey(digest([]byte(f.cookie.Value)))).Result()
must(t, err)
var stored storedCaptcha
must(t, json.Unmarshal([]byte(value), &stored))
if len(stored.Solution) != 0 || stored.ProofHash == "" || strings.Contains(value, ticket) {
t.Fatal("proof must be hashed and solution removed")
}
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("position replay accepted")
}
input := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: ch["captcha_id"].(string), CaptchaTicket: ticket}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", input, f.cookie, f.csrf, "")
if w.Code != 200 || f.sender.calls != 1 || provider.calls != 0 {
t.Fatal("local proof did not authorize SMS independently", w.Body.String())
}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", input, f.cookie, f.csrf, "")
if w.Code != 401 || f.sender.calls != 1 {
t.Fatal("proof reused")
}
}
func TestLocalSlidePasswordUsesLocalProof(t *testing.T) {
f := newLoginFixture(t)
account, csrf := f.smsAccount()
w, _ := f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
setCaptchaTestPolicy(t, f.fixture, "slide", "image")
provider := &fakeCaptchaProvider{}
f.s.options.Captcha = provider
f.newLoginSession()
ch, target := f.slideChallenge()
w, proof := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
w, _ = f.request("POST", "/api/v1/web/login/password", PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!", CaptchaID: ch["captcha_id"].(string), CaptchaTicket: proof["captcha_ticket"].(string)}, f.cookie, f.csrf, "")
if w.Code != 200 || provider.calls != 0 {
t.Fatal("password did not use local proof", w.Body.String())
}
}
func TestLocalSlidePolicyAuditAndMiniRestriction(t *testing.T) {
f := newFixture(t)
setCaptchaTestPolicy(t, f, "image", "image")
f.s.options.TokenVerifier = captchaPolicyToken{f.p}
policy, err := f.s.readCaptchaPolicy(f.ctx, f.s.pool)
must(t, err)
call := func(in captchaPolicy) (any, error) {
raw, _ := json.Marshal(in)
r := httptest.NewRequest("POST", "https://platform.example/api/v1/ops/login-captcha-policy", bytes.NewReader(raw))
r.Header.Set("Authorization", "Bearer test")
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Tenant-ID", f.tenantPublic)
return f.s.captchaPolicyHTTP(r, uuid.NewString())
}
if _, err = call(captchaPolicy{SaaS: "slide", Mini: "slide", Version: policy.Version}); err == nil {
t.Fatal("mini slide enabled before release")
}
out, err := call(captchaPolicy{SaaS: "slide", Mini: "image", Version: policy.Version})
must(t, err)
if out.(map[string]any)["saas_mode"] != "slide" {
t.Fatal("slide policy not persisted")
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM audit_event WHERE tenant_id=$1 AND action='login.captcha.policy.update'`, f.tenant).Scan(&count))
if count != 1 {
t.Fatal("slide switch missing audit")
}
if _, err = call(captchaPolicy{SaaS: "image", Mini: "image", Version: policy.Version}); err == nil {
t.Fatal("stale policy accepted")
}
}
func TestLocalSlideWrongPositionConsumedAndRefreshProtected(t *testing.T) {
f := newSlideLoginFixture(t)
ch, target := f.slideChallenge()
wrong := target
wrong.X += 20
w, _ := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, wrong), f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("wrong position accepted")
}
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("failed challenge reused")
}
old, oldTarget := f.slideChallenge()
current, currentTarget := f.slideChallenge()
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(old, oldTarget), f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("refreshed challenge accepted")
}
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(current, currentTarget), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal("old request destroyed fresh challenge")
}
key := f.s.captchaKey(digest([]byte(f.cookie.Value)))
oldValue, err := f.s.redis.Get(f.ctx, key).Result()
must(t, err)
f.slideChallenge()
updated, err := f.s.redis.Eval(f.ctx, replaceSlideRecord, []string{key}, oldValue, "invalid overwrite").Int64()
must(t, err)
if updated != 0 {
t.Fatal("delayed proof overwrote a refresh")
}
}
func TestLocalSlideExpiryPolicyClientAndForgery(t *testing.T) {
f := newSlideLoginFixture(t)
hash := digest([]byte(f.cookie.Value))
ch, target := f.slideChallenge()
must(t, f.s.redis.PExpire(f.ctx, f.s.captchaKey(hash), -time.Second).Err())
w, _ := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("expired challenge accepted")
}
ch, target = f.slideChallenge()
w, proof := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
mini := context.WithValue(f.ctx, captchaRequestKey{}, captchaRequestInfo{client: "mini_program"})
if f.s.verifyLoginCaptcha(mini, hash, ch["captcha_id"].(string), "", proof["captcha_ticket"].(string), "") == nil {
t.Fatal("cross-client proof accepted")
}
ch, target = f.slideChallenge()
w, proof = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
_, err := f.s.pool.Exec(f.ctx, `UPDATE login_captcha_policy SET version=version+1`)
must(t, err)
if f.s.verifyLoginCaptcha(f.ctx, hash, ch["captcha_id"].(string), "", proof["captcha_ticket"].(string), "") == nil {
t.Fatal("old-policy proof accepted")
}
ch, _ = f.slideChallenge()
if f.s.verifyLoginCaptcha(f.ctx, hash, ch["captcha_id"].(string), "", randomToken(32), "") == nil {
t.Fatal("unverified position authorized proof")
}
ch, target = f.slideChallenge()
w, proof = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
must(t, f.s.redis.PExpire(f.ctx, f.s.captchaKey(hash), -time.Second).Err())
if f.s.verifyLoginCaptcha(f.ctx, hash, ch["captcha_id"].(string), "", proof["captcha_ticket"].(string), "") == nil {
t.Fatal("expired proof accepted")
}
}
func TestLocalSlideHTTPOriginCSRFSessionAndStrictCoordinates(t *testing.T) {
f := newSlideLoginFixture(t)
ch, target := f.slideChallenge()
w, _ := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, "wrong", "")
if w.Code != 403 {
t.Fatal("invalid CSRF accepted")
}
w, session := f.request("GET", "/api/v1/web/login/session", nil, nil, "", "")
other := w.Result().Cookies()[0]
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), other, session["csrf_token"].(string), "")
if w.Code != 401 {
t.Fatal("cross-session position accepted")
}
call := func(raw, origin string) int {
r := httptest.NewRequest("POST", "https://platform.example/api/v1/web/login/captcha/verify", strings.NewReader(raw))
r.RemoteAddr = f.ip
r.AddCookie(f.cookie)
r.Header.Set("Origin", origin)
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-CSRF-Token", f.csrf)
r.Header.Set("X-Request-ID", uuid.NewString())
w := httptest.NewRecorder()
f.mux.ServeHTTP(w, r)
return w.Code
}
raw, _ := json.Marshal(slideInput(ch, target))
if call(string(raw), "https://other.example") != 403 {
t.Fatal("cross-origin accepted")
}
for _, bad := range []string{
`{"captcha_id":"` + ch["captcha_id"].(string) + `","x":1.5,"y":10}`,
`{"captcha_id":"` + ch["captcha_id"].(string) + `","x":10}`,
`{"captcha_id":"` + ch["captcha_id"].(string) + `","x":null,"y":10}`,
`{"captcha_id":"` + ch["captcha_id"].(string) + `","x":10,"y":10,"mode":"image"}`,
} {
if call(bad, "https://platform.example") != 400 {
t.Fatal("malformed coordinates accepted")
}
}
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal("unauthorized request consumed challenge")
}
}
func TestLocalSlideConcurrentProofAndConsumeOnce(t *testing.T) {
f := newSlideLoginFixture(t)
ch, target := f.slideChallenge()
x, y := target.X, target.Y
hash := digest([]byte(f.cookie.Value))
var wg sync.WaitGroup
var winners atomic.Int32
var ticket string
var ticketMu sync.Mutex
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
out, err := f.s.verifySlideCaptcha(f.ctx, hash, SlideCaptchaVerifyInput{ID: ch["captcha_id"].(string), X: &x, Y: &y}, func(tx pgx.Tx) error { return f.s.checkLoginSession(f.ctx, tx, hash) })
if err == nil {
winners.Add(1)
ticketMu.Lock()
ticket = out["captcha_ticket"].(string)
ticketMu.Unlock()
}
}()
}
wg.Wait()
if winners.Load() != 1 {
t.Fatal("concurrent position generated multiple proofs")
}
winners.Store(0)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if f.s.verifyLoginCaptcha(f.ctx, hash, ch["captcha_id"].(string), "", ticket, "") == nil {
winners.Add(1)
}
}()
}
wg.Wait()
if winners.Load() != 1 {
t.Fatal("concurrent proof consumed more than once")
}
}
func TestLocalSlideOperatorAndSetupRoutes(t *testing.T) {
f := newOperatorLoginFixture(t)
setCaptchaTestPolicy(t, f.fixture, "slide", "image")
w, ch := f.request("POST", "/api/v1/web/operator/login/captcha", struct{}{}, f.cookie, f.csrf, "")
if w.Code != 200 || ch["mode"] != "slide" {
t.Fatal("operator slide unavailable")
}
hash := digest([]byte(f.cookie.Value))
value, err := f.s.redis.Get(f.ctx, f.s.captchaKey(hash)).Result()
must(t, err)
var stored storedCaptcha
must(t, json.Unmarshal([]byte(value), &stored))
plain, err := f.s.crypt.open(stored.Solution, slideAAD(hash, stored))
must(t, err)
var target slideSolution
must(t, json.Unmarshal(plain, &target))
w, _ = f.request("POST", "/api/v1/web/operator/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal("operator position rejected", w.Body.String())
}
setup := newSlideLoginFixture(t)
setup.s.options.Enabled = true
token, csrf := randomToken(32), randomToken(32)
_, err = setup.s.pool.Exec(setup.ctx, `UPDATE onboarding_web_session SET token_hash=$1,csrf_hash=$2 WHERE token_hash=$3`, digest([]byte(token)), digest([]byte(csrf)), setup.web.Hash)
must(t, err)
cookie := &http.Cookie{Name: setup.s.cookieName(false), Value: token}
w, ch = setup.request("POST", "/api/v1/web/auth/captcha", struct{}{}, cookie, csrf, "")
if w.Code != 200 || ch["mode"] != "slide" {
t.Fatal("setup slide unavailable", w.Body.String())
}
hash = digest([]byte(token))
value, err = setup.s.redis.Get(setup.ctx, setup.s.captchaKey(hash)).Result()
must(t, err)
must(t, json.Unmarshal([]byte(value), &stored))
plain, err = setup.s.crypt.open(stored.Solution, slideAAD(hash, stored))
must(t, err)
must(t, json.Unmarshal(plain, &target))
w, _ = setup.request("POST", "/api/v1/web/auth/captcha/verify", slideInput(ch, target), cookie, csrf, "")
if w.Code != 200 {
t.Fatal("setup position rejected", w.Body.String())
}
}
+8 -8
View File
@@ -14,12 +14,12 @@ func (s *Service) accountSessionHTTP(r *http.Request) (any, error) {
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)))
token := s.sessionCSRF("ACCOUNT-CSRF1", cookie.Value)
_, err = s.pool.Exec(r.Context(), `UPDATE `+s.accountSessionTable()+` 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) {
if c, e := r.Cookie(s.cookieName(false)); !s.operatorLogin && 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
@@ -39,14 +39,14 @@ func (s *Service) logoutHTTP(w http.ResponseWriter, r *http.Request) (any, error
return nil, err
}
// Expired cookies can be cleared without treating them as a live login.
for _, account := range []bool{true, false} {
for _, account := range s.logoutKinds() {
c, e := r.Cookie(s.cookieName(account))
if e != nil {
continue
}
table := "onboarding_web_session"
if account {
table = "onboarding_account_session"
table = s.accountSessionTable()
}
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)
@@ -64,20 +64,20 @@ func (s *Service) logoutHTTP(w http.ResponseWriter, r *http.Request) (any, error
}
}
}
for _, account := range []bool{true, false} {
for _, account := range s.logoutKinds() {
c, e := r.Cookie(s.cookieName(account))
if e != nil {
continue
}
table := "onboarding_web_session"
if account {
table = "onboarding_account_session"
table = s.accountSessionTable()
}
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} {
for _, account := range s.logoutKinds() {
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
+13
View File
@@ -61,5 +61,18 @@ func (s *Service) MaintainOnce(ctx context.Context) error {
return err
}
_, err = s.pool.Exec(ctx, `DELETE FROM device_time_challenge WHERE expires_at<now()-interval '1 day'`)
if err != nil {
return err
}
// Keep one day for rate limiting; delete expired OTP ciphertext with its session.
_, err = s.pool.Exec(ctx, `DELETE FROM customer_login_session WHERE expires_at<now()-interval '1 day'`)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `DELETE FROM operator_login_session WHERE expires_at<now()-interval '1 day'`)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `DELETE FROM trusted_login_device WHERE expires_at<now() OR revoked_at<now()-interval '1 day'`)
return err
}
+175
View File
@@ -0,0 +1,175 @@
package onboarding
import (
"net"
"net/http"
"strings"
"time"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
// Native clients carry an explicit credential; browser cookies and CSRF rules
// remain unchanged. Realm prefixes never grant a role or create an account.
func miniCredential(header, scheme string) (string, string, bool) {
if !strings.HasPrefix(header, scheme+" ") {
return "", "", false
}
realm, token, ok := strings.Cut(strings.TrimPrefix(header, scheme+" "), ".")
return realm, token, ok && (realm == "customer" || realm == "operator") && validB64(token, 32)
}
func (s *Service) miniPrincipal(r *http.Request) (identity.Principal, error) {
realm, token, ok := miniCredential(r.Header.Get("Authorization"), "Mini")
if !ok || (realm == "operator") != s.operatorLogin {
return identity.Principal{}, fail(401, "LOGIN_REQUIRED", "请先登录对应账号")
}
return s.AccountPrincipal(r.Context(), token, "", false)
}
func (s *Service) miniHTTP(r *http.Request, requestID string) (any, error) {
if r.URL.RawQuery != "" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
return nil, errAccess
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/mini/")
ctx := r.Context()
if path == "login/session" {
raw, err := readBody(r)
if err != nil {
return nil, err
}
var in struct {
Portal string `json:"portal"`
}
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
if in.Portal != "customer" && in.Portal != "operator" {
return nil, errAccess
}
view := s
if in.Portal == "operator" {
view = s.operatorView()
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if err = s.limit(ctx, "mini-login:"+host, 30, time.Minute); err != nil {
return nil, err
}
token := randomToken(32)
_, err = s.pool.Exec(ctx, `INSERT INTO `+view.loginSessionTable()+`(token_hash,csrf_hash,expires_at) VALUES($1,$2,$3)`, digest([]byte(token)), digest([]byte(view.sessionCSRF("LOGIN-CSRF1", token))), s.now().Add(20*time.Minute))
if err != nil {
return nil, err
}
return map[string]any{"login_token": in.Portal + "." + token, "expires_in": 1200, "sms_enabled": s.options.SMSLoginEnabled}, nil
}
if strings.HasPrefix(path, "login/") {
realm, token, ok := miniCredential(r.Header.Get("Authorization"), "MiniLogin")
if !ok {
return nil, fail(401, "LOGIN_SESSION_EXPIRED", "请重新获取验证码")
}
view := s
if realm == "operator" {
view = s.operatorView()
}
hash := digest([]byte(token))
var live bool
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM `+view.loginSessionTable()+` WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now())`, hash).Scan(&live); err != nil {
return nil, err
}
if !live {
return nil, fail(401, "LOGIN_SESSION_EXPIRED", "请重新获取验证码")
}
raw, err := readBody(r)
if err != nil {
return nil, err
}
if path == "login/captcha" {
if err = StrictJSON(raw, &struct{}{}); err != nil {
return nil, err
}
return view.loginCaptcha(ctx, hash)
}
if path == "login/wechat" {
return decodeCall(raw, func(in WeChatLoginInput) (map[string]any, error) { return view.weChatLogin(ctx, hash, in) })
}
if path == "login/wechat-link" {
return decodeCall(raw, func(in WeChatPasswordInput) (map[string]any, error) { return view.weChatPasswordLogin(ctx, hash, in) })
}
check := func(tx pgx.Tx) error { return view.checkLoginSession(ctx, tx, hash) }
if path == "login/sms/send" {
return decodeCall(raw, func(in SMSSendInput) (map[string]any, error) {
return view.sendSMS(ctx, hash, requestID, in, view.loginSMSTable(), check)
})
}
if path == "login/sms/verify" {
var in SMSVerifyInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
out, err := view.verifySMS(ctx, hash, in, view.loginSMSTable(), check, func(tx pgx.Tx, p identity.Principal) (BrowserCredentials, error) {
return view.attachCustomerLogin(ctx, tx, hash, p, true)
})
if err != nil {
return nil, err
}
return map[string]any{"access_token": realm + "." + out.AccountToken, "portal": realm, "expires_in": 86400, "trusted_device_token": out.TrustedDeviceToken, "trusted_device_expires_in": int(trustedLoginLifetime.Seconds())}, nil
}
var in PasswordLoginInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
out, err := view.passwordLogin(ctx, hash, in)
if err != nil {
return nil, err
}
return map[string]any{"access_token": realm + "." + out.AccountToken, "portal": realm, "expires_in": 86400}, nil
}
realm, token, ok := miniCredential(r.Header.Get("Authorization"), "Mini")
if !ok {
return nil, fail(401, "LOGIN_REQUIRED", "请先登录")
}
view := s
if realm == "operator" {
view = s.operatorView()
}
p, err := view.miniPrincipal(r)
if err != nil {
return nil, err
}
if path == "account" {
return map[string]any{"authenticated": true, "portal": realm, "display_name": p.DisplayName}, nil
}
raw, err := readBody(r)
if err != nil {
return nil, err
}
if path == "logout" {
if err = StrictJSON(raw, &struct{}{}); err != nil {
return nil, err
}
_, err = s.pool.Exec(ctx, `UPDATE `+view.accountSessionTable()+` SET revoked_at=COALESCE(revoked_at,now()) WHERE token_hash=$1`, digest([]byte(token)))
return map[string]any{"signed_out": true}, err
}
if realm != "customer" {
return nil, fail(403, "CUSTOMER_REQUIRED", "请使用家庭账号绑定设备")
}
if err = s.limit(ctx, "mini-ble:"+p.Issuer+":"+p.Subject, 90, time.Minute); err != nil {
return nil, err
}
switch path {
case "device-subject":
return decodeCall(raw, func(in AssignDeviceSubjectInput) (map[string]any, error) {
return s.AssignDeviceSubject(ctx, p, r.Header.Get("X-Request-ID"), in)
})
case "ble-device-state":
return decodeCall(raw, func(in BLEDeviceInput) (map[string]any, error) { return s.BLEDeviceState(ctx, p, in) })
case "ble-session":
return decodeCall(raw, func(in BLESessionInput) (map[string]any, error) { return s.BLESession(ctx, p, in) })
case "ble-bind-tokens":
return decodeCall(raw, func(in BLETokenInput) (map[string]any, error) { return s.CreateBLEToken(ctx, p, in) })
case "ble-bind-status":
return decodeCall(raw, func(in BLEBindInput) (map[string]any, error) { return s.BLEStatus(ctx, p, in) })
}
return nil, fail(404, "NOT_FOUND", "接口不存在")
}
+197
View File
@@ -0,0 +1,197 @@
package onboarding
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
func miniRequest(t *testing.T, s *Service, method, path, auth string, in any) (int, map[string]any) {
t.Helper()
raw, _ := json.Marshal(in)
r := httptest.NewRequest(method, "https://platform.example"+path, bytes.NewReader(raw))
r.RemoteAddr = "127.0.0.1:3456"
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Request-ID", uuid.NewString())
r.Header.Set("Authorization", auth)
w := httptest.NewRecorder()
mux := http.NewServeMux()
s.RegisterRoutes(mux)
mux.ServeHTTP(w, r)
if len(w.Result().Cookies()) != 0 {
t.Fatal("native API issued ambient cookie")
}
var out map[string]any
must(t, json.Unmarshal(w.Body.Bytes(), &out))
data, _ := out["data"].(map[string]any)
return w.Code, data
}
func TestMiniCredentialsStrictRealm(t *testing.T) {
good := "customer." + randomToken(32)
if _, _, ok := miniCredential("Mini "+good, "Mini"); !ok {
t.Fatal("valid token rejected")
}
for _, value := range []string{"Bearer " + good, "Mini admin." + randomToken(32), "Mini customer.short", "Mini " + good + "\n", "MiniLogin " + good} {
if _, _, ok := miniCredential(value, "Mini"); ok {
t.Fatal("malformed token accepted")
}
}
}
func TestMiniSMSLoginSharesCustomerIdentityAndNoCookies(t *testing.T) {
f := newLoginFixture(t)
_, err := f.s.ProvisionCustomerPhone(f.ctx, f.web.UserID, f.user.Issuer, f.user.Subject, f.phone, "CustomerOnly123!", uuid.NewString())
must(t, err)
status, session := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "customer"})
if status != 200 || session["sms_enabled"] != true {
t.Fatal("mini SMS session unavailable", status)
}
loginToken := session["login_token"].(string)
_, token, _ := miniCredential("MiniLogin "+loginToken, "MiniLogin")
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/captcha", "MiniLogin "+loginToken, struct{}{})
if status != 200 {
t.Fatal("mini captcha unavailable", status)
}
in := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: f.knownCaptchaFor(digest([]byte(token))), CaptchaCode: "654321"}
status, challenge := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/send", "MiniLogin "+loginToken, in)
if status != 200 || f.sender.calls != 1 || challenge["sms_challenge_id"] == nil {
t.Fatal("mini SMS was not sent", status, challenge)
}
status, result := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+loginToken, SMSVerifyInput{ChallengeID: challenge["sms_challenge_id"].(string), Code: f.sender.code})
if status != 200 || result["portal"] != "customer" {
t.Fatal("mini SMS login failed", status, result)
}
auth := "Mini " + result["access_token"].(string)
status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", auth, nil)
if status != 200 {
t.Fatal("mini account did not inherit customer identity", status)
}
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+loginToken, SMSVerifyInput{ChallengeID: challenge["sms_challenge_id"].(string), Code: f.sender.code})
if status != 401 {
t.Fatal("mini SMS challenge was reused", status)
}
}
func TestMiniSMSOperatorCannotSelfRegister(t *testing.T) {
f := newOperatorLoginFixture(t)
status, session := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "operator"})
if status != 200 {
t.Fatal(status)
}
loginToken := session["login_token"].(string)
_, token, _ := miniCredential("MiniLogin "+loginToken, "MiniLogin")
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/captcha", "MiniLogin "+loginToken, struct{}{})
if status != 200 {
t.Fatal(status)
}
in := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: f.knownCaptchaFor(digest([]byte(token))), CaptchaCode: "654321"}
status, challenge := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/send", "MiniLogin "+loginToken, in)
if status != 200 {
t.Fatal(status)
}
verify := SMSVerifyInput{ChallengeID: challenge["sms_challenge_id"].(string), Code: f.sender.code}
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+loginToken, verify)
if status != 403 {
t.Fatal("unprovisioned operator registered", status)
}
_, err := f.s.ProvisionOperatorPhone(f.ctx, f.operatorID, f.p.Issuer, f.p.Subject, f.phone, "OperatorOnly123!", uuid.NewString())
must(t, err)
status, result := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+loginToken, verify)
if status != 200 || result["portal"] != "operator" {
t.Fatal("provisioned operator could not log in", status, result)
}
auth := "Mini " + result["access_token"].(string)
status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", auth, nil)
if status != 200 {
t.Fatal("operator session rejected", status)
}
status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", strings.Replace(auth, "operator.", "customer.", 1), nil)
if status != 401 {
t.Fatal("operator token crossed customer realm", status)
}
}
func TestMiniPasswordRealmRevocationAndNoCookies(t *testing.T) {
f := newLoginFixture(t)
account, csrf := f.smsAccount()
w, _ := f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Code)
}
status, login := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "customer"})
if status != 200 {
t.Fatal(status)
}
loginToken := login["login_token"].(string)
_, token, _ := miniCredential("MiniLogin "+loginToken, "MiniLogin")
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/captcha", "MiniLogin "+loginToken, struct{}{})
if status != 200 {
t.Fatal(status)
}
id := f.knownCaptchaFor(digest([]byte(token)))
status, result := miniRequest(t, f.s, "POST", "/api/v1/mini/login/password", "MiniLogin "+loginToken, PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!", CaptchaID: id, CaptchaCode: "654321", TrustedDeviceToken: f.trusted.Value})
if status != 200 {
t.Fatal(status)
}
auth := "Mini " + result["access_token"].(string)
status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", auth, nil)
if status != 200 {
t.Fatal(status)
}
status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", strings.Replace(auth, "customer.", "operator.", 1), nil)
if status != 401 {
t.Fatal("realm substitution", status)
}
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/captcha", "MiniLogin "+loginToken, struct{}{})
if status != 401 {
t.Fatal("consumed login reused", status)
}
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/logout", auth, struct{}{})
if status != 200 {
t.Fatal(status)
}
status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", auth, nil)
if status != 401 {
t.Fatal("revoked session accepted", status)
}
// Existing web writes still require their own Origin and CSRF.
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ChangedOnly123!"}, account, "wrong", "")
if w.Code != 403 {
t.Fatal("web CSRF weakened")
}
}
func TestBLEAutomaticSession(t *testing.T) {
f := newFixture(t)
in := BLESessionInput{DeviceID: f.d.FactoryID, Model: f.d.Model, KeyVersion: f.d.KeyVersion, ClientNonce: randomToken(16), ServerNonce: randomToken(16)}
in.Proof = domainMAC(f.d.Key, "BLE-PROOF1\n"+bleTranscript(in))
result, err := f.s.BLESession(f.ctx, f.user, in)
must(t, err)
if result["session_password"] != domainMAC(f.d.Key, "BLE-PASS1\n"+bleTranscript(in)) || result["session_password"] == in.Proof {
t.Fatal("wrong or non-domain-separated password")
}
_, err = f.s.BLESession(f.ctx, f.user, in)
must(t, err)
bad := in
bad.ServerNonce = randomToken(16)
_, err = f.s.BLESession(f.ctx, f.user, bad)
if err == nil {
t.Fatal("tampered transcript accepted")
}
_, err = f.s.BLESession(f.ctx, f.p, in)
if err == nil {
t.Fatal("operator obtained family pairing password")
}
must(t, f.s.redis.Set(f.ctx, "onboarding:ble-link:"+digest([]byte(in.Proof)), "different-account", time.Minute).Err())
_, err = f.s.BLESession(f.ctx, f.user, in)
if err == nil {
t.Fatal("cross-account replay accepted")
}
}
+58
View File
@@ -0,0 +1,58 @@
package onboarding
import (
"context"
"net/http"
)
// Each view is a request-local value. Pools and immutable configuration are
// shared; customer and operator credentials, sessions and cookies are separate.
func (s *Service) operatorView() *Service {
view := *s
view.operatorLogin = true
return &view
}
func (s *Service) loginSessionTable() string {
if s.operatorLogin {
return "operator_login_session"
}
return "customer_login_session"
}
func (s *Service) loginSMSTable() string {
if s.operatorLogin {
return "operator_sms_challenge"
}
return "customer_sms_challenge"
}
func (s *Service) phoneCredentialTable() string {
if s.operatorLogin {
return "operator_phone_credential"
}
return "customer_phone_credential"
}
func (s *Service) accountSessionTable() string {
if s.operatorLogin {
return "operator_account_session"
}
return "onboarding_account_session"
}
func (s *Service) accountPredicate() string {
if s.operatorLogin {
return "ua.account_kind='operator' AND ua.platform_role IN ('ops_admin','ops_operator','ops_approver','ops_viewer')"
}
return "ua.account_kind='customer' AND ua.platform_role='customer'"
}
func (s *Service) logoutKinds() []bool {
if s.operatorLogin {
return []bool{true}
}
return []bool{true, false}
}
func (s *Service) OperatorAuth(next http.Handler) http.Handler {
return s.operatorView().CustomerAuth(next)
}
func (s *Service) ProvisionOperatorPhone(ctx context.Context, userID, issuer, subject, phone, password, requestID string) (bool, error) {
return s.operatorView().provisionPhone(ctx, userID, issuer, subject, phone, password, requestID)
}
+158
View File
@@ -0,0 +1,158 @@
package onboarding
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
)
func newOperatorLoginFixture(t *testing.T) *loginFixture {
f := newLoginFixture(t)
f.s = f.s.operatorView()
f.newLoginSession()
f.mux.Handle("POST /operator-write", f.s.CustomerAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })))
return f
}
func operatorPasswordSession(t *testing.T, f *loginFixture, password string) (*http.Cookie, string) {
t.Helper()
if f.trusted == nil || f.trusted.Name != f.s.trustedLoginCookieName() {
f.trustDeviceForTest()
}
in := PasswordLoginInput{Phone: f.phone, Password: password, CaptchaID: f.knownCaptcha(), CaptchaCode: "654321"}
w, data := f.request("POST", "/api/v1/web/login/password", in, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
for _, c := range w.Result().Cookies() {
if c.Name == f.s.cookieName(true) {
return c, data["csrf_token"].(string)
}
}
t.Fatal("missing operator cookie")
return nil, ""
}
func TestOperatorPhoneLoginKeepsRolesAndIsolatesCustomerSession(t *testing.T) {
f := newOperatorLoginFixture(t)
_, err := f.s.ProvisionOperatorPhone(f.ctx, f.operatorID, f.p.Issuer, f.p.Subject, f.phone, "OperatorOnly123!", uuid.NewString())
must(t, err)
account, csrf := operatorPasswordSession(t, f, "OperatorOnly123!")
if account.Name != "__Host-platform-operator" || !account.Secure || !account.HttpOnly || account.SameSite != http.SameSiteLaxMode {
t.Fatal("operator cookie protections")
}
p, err := f.s.AccountPrincipal(f.ctx, account.Value, csrf, true)
must(t, err)
if p.Subject != f.p.Subject {
t.Fatal("wrong operator identity")
}
w, _ := f.request("POST", "/operator-write", struct{}{}, account, "wrong", "")
if w.Code != 403 {
t.Fatal("CSRF bypass")
}
w, _ = f.request("POST", "/operator-write", struct{}{}, account, csrf, "")
if w.Code != 204 {
t.Fatal(w.Body.String())
}
r := httptest.NewRequest("POST", "https://platform.example/operator-write", strings.NewReader("{}"))
r.Header.Set("Origin", "https://other.example")
r.Header.Set("X-CSRF-Token", csrf)
r.AddCookie(account)
w2 := httptest.NewRecorder()
f.mux.ServeHTTP(w2, r)
if w2.Code != 403 {
t.Fatal("cross-origin operator write")
}
_, state := f.request("GET", "/api/v1/web/account/password", nil, account, "", "")
if state["password_configured"] != true || state["sms_verified_recently"] != false {
t.Fatal("password login granted SMS proof")
}
// A phone can be registered in both portals without linking their identities.
customer := *f.s
customer.operatorLogin = false
_, err = customer.ProvisionCustomerPhone(f.ctx, f.web.UserID, f.user.Issuer, f.user.Subject, f.phone, "CustomerOnly123!", uuid.NewString())
must(t, err)
if _, err = customer.AccountPrincipal(f.ctx, account.Value, csrf, true); err == nil {
t.Fatal("operator token crossed into customer table")
}
cf := *f
cf.fixture = &fixture{}
*cf.fixture = *f.fixture
cf.s = &customer
cf.newLoginSession()
cc, ccsrf := operatorPasswordSession(t, &cf, "CustomerOnly123!")
if _, err = f.s.AccountPrincipal(f.ctx, cc.Value, ccsrf, true); err == nil {
t.Fatal("customer token granted operator access")
}
f.newLoginSession()
second, _ := operatorPasswordSession(t, f, "OperatorOnly123!")
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ChangedOperator123!", CurrentPassword: "OperatorOnly123!"}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
if _, err = f.s.AccountPrincipal(f.ctx, second.Value, "", false); err == nil {
t.Fatal("password change kept another operator session")
}
if _, err = customer.AccountPrincipal(f.ctx, cc.Value, "", false); err != nil {
t.Fatal("operator password change revoked customer", err)
}
w, _ = f.request("POST", "/api/v1/web/auth/logout", struct{}{}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
if _, err = f.s.AccountPrincipal(f.ctx, account.Value, "", false); err == nil {
t.Fatal("operator logout not revoked")
}
if _, err = customer.AccountPrincipal(f.ctx, cc.Value, "", false); err != nil {
t.Fatal("operator logout revoked customer", err)
}
}
func TestOperatorSMSOnlyAllowsProvisionedActiveOperators(t *testing.T) {
f := newOperatorLoginFixture(t)
if _, err := f.s.ProvisionOperatorPhone(f.ctx, f.web.UserID, f.user.Issuer, f.user.Subject, f.phone, "OperatorOnly123!", uuid.NewString()); err == nil {
t.Fatal("customer promoted by provisioning")
}
id := f.send()
w, _ := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 403 {
t.Fatal("unregistered operator accepted", w.Body.String())
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM operator_phone_credential WHERE phone_digest=$1`, domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone)).Scan(&count))
if count != 0 {
t.Fatal("SMS registered operator")
}
_, err := f.s.ProvisionOperatorPhone(f.ctx, f.operatorID, f.p.Issuer, f.p.Subject, f.phone, "OperatorOnly123!", uuid.NewString())
must(t, err)
w, data := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
var account *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == f.s.cookieName(true) {
account = c
}
}
if account == nil {
t.Fatal("missing operator SMS session")
}
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ResetOperator123!"}, account, data["csrf_token"].(string), "")
if w.Code != 200 {
t.Fatal("SMS reset failed", w.Body.String())
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE user_account SET status='disabled' WHERE id=$1::uuid`, f.operatorID)
must(t, err)
t.Cleanup(func() {
_, _ = f.s.pool.Exec(f.ctx, `UPDATE user_account SET status='active' WHERE id=$1::uuid`, f.operatorID)
})
if _, err = f.s.AccountPrincipal(f.ctx, account.Value, "", false); err == nil {
t.Fatal("disabled operator session accepted")
}
}
+4 -4
View File
@@ -86,7 +86,7 @@ func (s *Service) OpsList(ctx context.Context, p identity.Principal, tenant stri
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)
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,EXISTS(SELECT 1 FROM device_binding b WHERE b.device_id=d.id AND b.unbound_at IS 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
}
@@ -95,11 +95,11 @@ func (s *Service) OpsList(ctx context.Context, p identity.Principal, tenant stri
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 {
var released, bound bool
if err = rows.Scan(&id, &factory, &model, &gen, &rev, &released, &bound, &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})
items = append(items, map[string]any{"platform_device_id": id, "device_id": factory, "model": model, "binding_generation": gen, "device_revision": rev, "released": released, "bound": bound, "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()
}
+404
View File
@@ -0,0 +1,404 @@
package onboarding
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"unicode/utf8"
"data-terminal-cloud/internal/identity"
"github.com/dchest/captcha"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/argon2"
)
// Bound expensive password work independently of database and request concurrency.
var passwordWorkers = make(chan struct{}, 2)
var invalidPassword = fail(401, "PASSWORD_INVALID", "手机号或密码不正确")
const passwordPrefix = "$argon2id$v=19$m=65536,t=3,p=4$"
func passwordWork(ctx context.Context, fn func() string) (string, error) {
select {
case passwordWorkers <- struct{}{}:
defer func() { <-passwordWorkers }()
if err := ctx.Err(); err != nil {
return "", err
}
return fn(), nil
default:
return "", fail(503, "LOGIN_BUSY", "登录繁忙,请稍后重试")
}
}
func hashPassword(ctx context.Context, password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
return passwordWork(ctx, func() string {
hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
return passwordPrefix + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(hash)
})
}
func verifyPassword(ctx context.Context, encoded, password string) (bool, error) {
// Missing credentials still perform the same password hash work.
salt, expected := make([]byte, 16), make([]byte, 32)
valid := false
if strings.HasPrefix(encoded, passwordPrefix) {
parts := strings.Split(strings.TrimPrefix(encoded, passwordPrefix), "$")
if len(parts) == 2 {
s, e1 := base64.RawStdEncoding.DecodeString(parts[0])
h, e2 := base64.RawStdEncoding.DecodeString(parts[1])
if e1 == nil && e2 == nil && len(s) == 16 && len(h) == 32 {
salt, expected, valid = s, h, true
}
}
}
actual, err := passwordWork(ctx, func() string { return string(argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)) })
return hmac.Equal([]byte(actual), expected) && valid, err
}
func validNewPassword(password string) bool {
n := utf8.RuneCountInString(password)
return utf8.ValidString(password) && n >= 6 && n <= 64 && len(password) <= 256
}
func (s *Service) captchaKey(sessionHash string) string {
return "onboarding:login-captcha:" + sessionHash
}
func (s *Service) loginCaptcha(ctx context.Context, sessionHash string) (map[string]any, error) {
return s.newCaptcha(ctx, sessionHash, func(tx pgx.Tx) error { return s.checkLoginSession(ctx, tx, sessionHash) })
}
func (s *Service) webCaptcha(ctx context.Context, w WebSession) (map[string]any, error) {
return s.newCaptcha(ctx, w.Hash, func(tx pgx.Tx) error {
d, err := readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return err
}
return s.checkWebActor(ctx, tx, w.Hash, d)
})
}
func (s *Service) newCaptcha(ctx context.Context, sessionHash string, check func(pgx.Tx) error) (map[string]any, error) {
if err := s.limit(ctx, "captcha:session:"+sessionHash, 30, 10*time.Minute); err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err = check(tx); err != nil {
return nil, err
}
if s.saasCaptchaDisabled(ctx) {
return map[string]any{"mode": "none", "captcha_id": "", "expires_at_ms": 0}, tx.Commit(ctx)
}
id := uuid.NewString()
policy, err := s.readCaptchaPolicy(ctx, tx)
if err != nil {
return nil, err
}
client := captchaRequest(ctx).client
mode := policy.mode(client)
stored := storedCaptcha{ID: id, Mode: mode, Client: client, Version: policy.Version}
if mode == "slide" {
out, e := s.newSlideCaptcha(ctx, sessionHash, &stored)
if e != nil {
return nil, e
}
if e = tx.Commit(ctx); e != nil {
return nil, e
}
return out, nil
}
if mode == "tencent" {
stored.AppID = s.captchaAppID(client)
if stored.AppID == "" {
return nil, captchaUnavailable()
}
if err = s.redis.Set(ctx, s.captchaKey(sessionHash), captchaJSON(stored), 2*time.Minute).Err(); err != nil {
return nil, captchaUnavailable()
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
return map[string]any{"captcha_id": id, "mode": mode, "captcha_app_id": stored.AppID, "expires_at_ms": s.now().Add(2 * time.Minute).UnixMilli()}, nil
}
digits := captcha.RandomDigits(6)
answer := make([]byte, len(digits))
for i, d := range digits {
answer[i] = '0' + d
}
var png bytes.Buffer
if _, err = captcha.NewImage(id, digits, 240, 80).WriteTo(&png); err != nil {
return nil, err
}
// Only a keyed digest is stored; the response contains a raster image, never the answer.
stored.AnswerHash = domainMAC(s.crypt.hashKey, "CAPTCHA1\n"+sessionHash+"\n"+id+"\n"+string(answer))
value := captchaJSON(stored)
if err = s.redis.Set(ctx, s.captchaKey(sessionHash), value, 2*time.Minute).Err(); err != nil {
return nil, fail(503, "CAPTCHA_UNAVAILABLE", "图片验证码暂不可用,请重试")
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
return map[string]any{"captcha_id": id, "mode": "image", "image": "data:image/png;base64," + base64.StdEncoding.EncodeToString(png.Bytes()), "expires_at_ms": s.now().Add(2 * time.Minute).UnixMilli()}, nil
}
func (s *Service) verifyLoginCaptcha(ctx context.Context, sessionHash, id, answer string, proof ...string) error {
if s.saasCaptchaDisabled(ctx) {
return nil
}
policy, err := s.readCaptchaPolicy(ctx, s.pool)
if err != nil {
return captchaUnavailable()
}
value, err := s.consumeCaptcha(ctx, sessionHash, id)
if err != nil && !errors.Is(err, redis.Nil) {
return fail(503, "CAPTCHA_UNAVAILABLE", "图片验证码暂不可用,请重试")
}
if err != nil || !uuidV4(id) {
return captchaInvalid()
}
client := captchaRequest(ctx)
var stored storedCaptcha
if json.Unmarshal([]byte(value), &stored) == nil {
if stored.ID != id || stored.Client != client.client || stored.Version != policy.Version || stored.Mode != policy.mode(client.client) {
return captchaInvalid()
}
if stored.Mode == "slide" {
if len(proof) != 2 || !validB64(proof[0], 32) || proof[1] != "" || answer != "" ||
len(stored.Solution) != 0 || stored.ProofHash == "" || stored.ExpiresAt <= s.now().UnixMilli() ||
!hmac.Equal([]byte(stored.ProofHash), []byte(s.slideProofHash(sessionHash, id, proof[0]))) {
return captchaInvalid()
}
return nil
}
if stored.Mode == "tencent" {
if len(proof) != 2 || proof[0] == "" || len(proof[0]) > 4096 || len(proof[1]) > 256 || strings.HasPrefix(proof[0], "trerror") {
return captchaInvalid()
}
if stored.AppID != s.captchaAppID(client.client) || s.options.Captcha == nil {
return captchaUnavailable()
}
// Burn a ticket before network I/O, across all sessions/realms/endpoints.
fresh, e := s.redis.SetNX(ctx, "onboarding:captcha-ticket:"+digest([]byte(proof[0])), "used", 10*time.Minute).Result()
if e != nil {
return captchaUnavailable()
}
if !fresh {
return captchaInvalid()
}
return s.options.Captcha.Verify(ctx, client.client, proof[0], proof[1], client.ip)
}
value = stored.ID + ":" + stored.AnswerHash
} else if policy.mode(client.client) != "image" {
return captchaInvalid()
}
expected := id + ":" + domainMAC(s.crypt.hashKey, "CAPTCHA1\n"+sessionHash+"\n"+id+"\n"+answer)
if err != nil || !uuidV4(id) || !smsCodePattern.MatchString(answer) || !hmac.Equal([]byte(value), []byte(expected)) {
return fail(401, "CAPTCHA_INVALID", "图片验证码错误或已过期,请重新输入")
}
return nil
}
func (s *Service) saasCaptchaDisabled(ctx context.Context) bool {
return s.options.SaaSCaptchaDisabled && captchaRequest(ctx).client == "saas"
}
type PasswordLoginInput struct {
TrustedDeviceToken string `json:"trusted_device_token,omitempty"`
CaptchaTicket string `json:"captcha_ticket,omitempty"`
CaptchaRandstr string `json:"captcha_randstr,omitempty"`
Phone string `json:"phone_e164"`
Password string `json:"password"`
CaptchaID string `json:"captcha_id"`
CaptchaCode string `json:"captcha_code"`
}
func (s *Service) passwordLogin(ctx context.Context, sessionHash string, in PasswordLoginInput) (BrowserCredentials, error) {
return s.passwordLoginLinked(ctx, sessionHash, in, "")
}
func (s *Service) passwordLoginLinked(ctx context.Context, sessionHash string, in PasswordLoginInput, openID string) (BrowserCredentials, error) {
var out BrowserCredentials
if !phonePattern.MatchString(in.Phone) || len(in.Password) == 0 || len(in.Password) > 256 {
return out, invalidPassword
}
if err := s.limit(ctx, "password:session:"+sessionHash, 20, 10*time.Minute); err != nil {
return out, err
}
if err := s.verifyLoginCaptcha(ctx, sessionHash, in.CaptchaID, in.CaptchaCode, in.CaptchaTicket, in.CaptchaRandstr); err != nil {
return out, err
}
phoneHash := domainMAC(s.crypt.hashKey, "PHONE1\n"+in.Phone)
if err := s.limit(ctx, "password:phone:"+phoneHash, 10, 10*time.Minute); err != nil {
return out, err
}
var encoded string
err := s.pool.QueryRow(ctx, `SELECT COALESCE(password_hash,'') FROM `+s.phoneCredentialTable()+` WHERE phone_digest=$1`, phoneHash).Scan(&encoded)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return out, err
}
ok, err := verifyPassword(ctx, encoded, in.Password)
if err != nil {
return out, err
}
if !ok {
return out, invalidPassword
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return out, err
}
defer tx.Rollback(ctx)
if err = s.checkLoginSession(ctx, tx, sessionHash); err != nil {
return out, err
}
var p identity.Principal
var current string
err = tx.QueryRow(ctx, `SELECT issuer,subject,COALESCE(password_hash,'') FROM `+s.phoneCredentialTable()+` WHERE phone_digest=$1 FOR SHARE`, phoneHash).Scan(&p.Issuer, &p.Subject, &current)
if errors.Is(err, pgx.ErrNoRows) || (err == nil && current != encoded) {
return out, invalidPassword
}
if err != nil {
return out, err
}
if err = s.requireTrustedLoginDevice(ctx, tx, phoneHash, current, in.TrustedDeviceToken); err != nil {
return out, err
}
if openID != "" {
if err = s.linkWeChatPhone(ctx, tx, openID, phoneHash); err != nil {
return out, err
}
}
out, err = s.attachCustomerLogin(ctx, tx, sessionHash, p, false)
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE `+s.accountSessionTable()+` SET phone_digest=$2 WHERE token_hash=$1`, digest([]byte(out.AccountToken)), phoneHash)
if err != nil {
return out, err
}
if err = tx.Commit(ctx); err != nil {
return out, err
}
return out, nil
}
type SetPasswordInput struct {
Password string `json:"password"`
CurrentPassword string `json:"current_password,omitempty"`
}
func (s *Service) accountPasswordHTTP(r *http.Request) (any, error) {
if r.URL.RawQuery != "" || r.Header.Get("Sec-Fetch-Site") == "cross-site" || (r.Method != http.MethodGet && !s.sameOrigin(r)) {
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, r.Header.Get("X-CSRF-Token"), r.Method != http.MethodGet)
if err != nil {
return nil, err
}
ctx := r.Context()
sessionHash := digest([]byte(cookie.Value))
if r.Method == http.MethodGet {
var masked string
var configured, recent bool
err = s.pool.QueryRow(ctx, `SELECT c.phone_masked,c.password_hash IS NOT NULL,COALESCE(ss.phone_verified_at>$2::timestamptz-interval '10 minutes',false) FROM `+s.accountSessionTable()+` ss JOIN `+s.phoneCredentialTable()+` c ON c.phone_digest=ss.phone_digest WHERE ss.token_hash=$1`, sessionHash, s.now()).Scan(&masked, &configured, &recent)
if errors.Is(err, pgx.ErrNoRows) {
return map[string]any{"available": false}, nil
}
if err != nil {
return nil, err
}
return map[string]any{"available": true, "phone_masked": masked, "password_configured": configured, "sms_verified_recently": recent}, nil
}
raw, err := readBody(r)
if err != nil {
return nil, err
}
var in SetPasswordInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
if !validNewPassword(in.Password) || len(in.CurrentPassword) > 256 {
return nil, fail(400, "PASSWORD_POLICY", "密码须为 6–64 个字符")
}
if err = s.limit(ctx, "password:set:"+p.Issuer+":"+p.Subject, 10, 10*time.Minute); err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var phoneHash, encoded string
var recent bool
// Credential locking serializes changes with login, preventing a login using a concurrently reset password.
err = tx.QueryRow(ctx, `SELECT phone_digest,COALESCE(password_hash,'') FROM `+s.phoneCredentialTable()+` WHERE issuer=$1 AND subject=$2 FOR UPDATE`, p.Issuer, p.Subject).Scan(&phoneHash, &encoded)
if errors.Is(err, pgx.ErrNoRows) {
return nil, fail(403, "SMS_REAUTH_REQUIRED", "请先使用短信验证码登录")
}
if err != nil {
return nil, err
}
err = tx.QueryRow(ctx, `SELECT COALESCE(phone_verified_at>$2::timestamptz-interval '10 minutes',false) FROM `+s.accountSessionTable()+` WHERE token_hash=$1 AND phone_digest=$3 AND revoked_at IS NULL AND expires_at>now() FOR UPDATE`, sessionHash, s.now(), phoneHash).Scan(&recent)
if errors.Is(err, pgx.ErrNoRows) {
return nil, fail(401, "LOGIN_REQUIRED", "请重新登录")
}
if err != nil {
return nil, err
}
if !recent {
if in.CurrentPassword == "" {
return nil, fail(403, "SMS_REAUTH_REQUIRED", "请使用短信验证码登录后设置密码,或输入当前密码")
}
ok, e := verifyPassword(ctx, encoded, in.CurrentPassword)
if e != nil {
return nil, e
}
if !ok {
return nil, fail(401, "CURRENT_PASSWORD_INVALID", "当前密码不正确")
}
}
hash, err := hashPassword(ctx, in.Password)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE `+s.phoneCredentialTable()+` SET password_hash=$2,updated_at=now() WHERE phone_digest=$1`, phoneHash, hash)
if err != nil {
return nil, err
}
if err = s.updateTrustedDevicesAfterPassword(ctx, tx, phoneHash, encoded, hash, s.trustedLoginCookie(r)); err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE `+s.accountSessionTable()+` ss SET phone_verified_at=NULL,revoked_at=CASE WHEN ss.token_hash=$3 THEN ss.revoked_at ELSE COALESCE(ss.revoked_at,now()) END FROM user_account ua WHERE ss.user_id=ua.id AND ua.oidc_issuer=$1 AND ua.oidc_subject=$2`, p.Issuer, p.Subject, sessionHash)
if err != nil {
return nil, err
}
if s.operatorLogin {
return map[string]any{"password_configured": true}, tx.Commit(ctx)
}
setupHash := ""
if c, e := r.Cookie(s.cookieName(false)); e == nil {
setupHash = digest([]byte(c.Value))
}
_, err = tx.Exec(ctx, `UPDATE onboarding_web_session SET revoked_at=COALESCE(revoked_at,now()) WHERE issuer=$1 AND subject=$2 AND token_hash<>$3`, p.Issuer, p.Subject, setupHash)
if err != nil {
return nil, err
}
return map[string]any{"password_configured": true}, tx.Commit(ctx)
}
+237
View File
@@ -0,0 +1,237 @@
package onboarding
import (
"bytes"
"context"
"encoding/base64"
"github.com/google/uuid"
"image/png"
"net/http"
"strings"
"testing"
"time"
)
func TestPhonePasswordHash(t *testing.T) {
ctx := context.Background()
a, err := hashPassword(ctx, "LocalOnly123!")
must(t, err)
b, err := hashPassword(ctx, "LocalOnly123!")
must(t, err)
if a == b || strings.Contains(a, "LocalOnly123!") {
t.Fatal("password not salted")
}
ok, err := verifyPassword(ctx, a, "LocalOnly123!")
must(t, err)
if !ok {
t.Fatal("valid password rejected")
}
for _, encoded := range []string{a, "", passwordPrefix + "bad$bad"} {
ok, err = verifyPassword(ctx, encoded, "incorrect")
must(t, err)
if ok {
t.Fatal("invalid password accepted")
}
}
if validNewPassword("short") || validNewPassword(strings.Repeat("a", 65)) || !validNewPassword("123456") || !validNewPassword(strings.Repeat("好", 6)) {
t.Fatal("password length policy")
}
}
func (f *loginFixture) newLoginSession() {
w, data := f.request("GET", "/api/v1/web/login/session", nil, nil, "", "")
if w.Code != 200 {
f.t.Fatal(w.Body.String())
}
f.cookie = w.Result().Cookies()[0]
f.csrf = data["csrf_token"].(string)
}
// A known answer is injected only into isolated test Redis, never via an HTTP bypass.
func (f *loginFixture) knownCaptcha() string {
return f.knownCaptchaFor(digest([]byte(f.cookie.Value)))
}
func (f *fixture) knownCaptchaFor(hash string) string {
id := uuid.NewString()
value := id + ":" + domainMAC(f.s.crypt.hashKey, "CAPTCHA1\n"+hash+"\n"+id+"\n654321")
must(f.t, f.s.redis.Set(f.ctx, f.s.captchaKey(hash), value, 2*time.Minute).Err())
return id
}
func (f *loginFixture) smsAccount() (*http.Cookie, string) {
id := f.send()
w, data := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 200 {
f.t.Fatal(w.Body.String())
}
for _, c := range w.Result().Cookies() {
if c.Name == f.s.cookieName(true) {
return c, data["csrf_token"].(string)
}
}
f.t.Fatal("no account cookie")
return nil, ""
}
func TestPhonePasswordSameIdentityAndResetSecurity(t *testing.T) {
f := newLoginFixture(t)
account, csrf := f.smsAccount()
w, _ := f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, "wrong", "")
if w.Code != 403 {
t.Fatal("missing CSRF accepted")
}
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ChangedOnly123!"}, account, csrf, "")
if w.Code != 403 {
t.Fatal("SMS proof reused")
}
smsPrincipal, err := f.s.AccountPrincipal(f.ctx, account.Value, csrf, true)
must(t, err)
f.s.options.SMSLoginEnabled = false
f.newLoginSession()
input := PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!", CaptchaID: f.knownCaptcha(), CaptchaCode: "654321"}
w, data := f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
var passwordCookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == f.s.cookieName(true) {
passwordCookie = c
}
}
if passwordCookie == nil {
t.Fatal("missing password cookie")
}
passwordCSRF := data["csrf_token"].(string)
passwordPrincipal, err := f.s.AccountPrincipal(f.ctx, passwordCookie.Value, passwordCSRF, true)
must(t, err)
if passwordPrincipal.Subject != smsPrincipal.Subject || passwordPrincipal.Issuer != smsPrincipal.Issuer {
t.Fatal("login methods created different identities")
}
w, state := f.request("GET", "/api/v1/web/account/password", nil, passwordCookie, "", "")
if w.Code != 200 || state["sms_verified_recently"] != false || state["password_configured"] != true {
t.Fatal("password login treated as SMS proof", w.Body.String())
}
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ChangedOnly123!", CurrentPassword: "wrong"}, passwordCookie, passwordCSRF, "")
if w.Code != 401 {
t.Fatal("wrong current password accepted")
}
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ChangedOnly123!", CurrentPassword: "LocalOnly123!"}, passwordCookie, passwordCSRF, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
if _, err = f.s.AccountPrincipal(f.ctx, account.Value, csrf, true); err == nil {
t.Fatal("password change did not revoke other sessions")
}
if _, err = f.s.AccountPrincipal(f.ctx, passwordCookie.Value, passwordCSRF, true); err != nil {
t.Fatal("password change revoked current session")
}
f.newLoginSession()
input.CaptchaID = f.knownCaptcha()
w, _ = f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if w.Code != 401 || !strings.Contains(w.Body.String(), "PASSWORD_INVALID") {
t.Fatal("old password accepted")
}
input.Password = "ChangedOnly123!"
input.CaptchaID = f.knownCaptcha()
w, _ = f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
}
func TestPhoneCaptchaBindingRefreshExpiryAndReplay(t *testing.T) {
f := newLoginFixture(t)
hash := digest([]byte(f.cookie.Value))
w, _ := f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, "wrong", "")
if w.Code != 403 {
t.Fatal("captcha CSRF missing")
}
old := f.knownCaptcha()
w, data := f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(data["image"].(string), "data:image/png;base64,"))
must(t, err)
img, err := png.Decode(bytes.NewReader(raw))
must(t, err)
if img.Bounds().Dx() != 240 {
t.Fatal("not a raster captcha")
}
if ttl := f.s.redis.TTL(f.ctx, f.s.captchaKey(hash)).Val(); ttl > 2*time.Minute || ttl <= 0 {
t.Fatal("captcha expiry missing")
}
if err = f.s.verifyLoginCaptcha(f.ctx, hash, old, "654321"); err == nil {
t.Fatal("refreshed captcha still valid")
}
id := f.knownCaptcha()
if err = f.s.verifyLoginCaptcha(f.ctx, hash, id, "wrong"); err == nil {
t.Fatal("wrong captcha accepted")
}
if err = f.s.verifyLoginCaptcha(f.ctx, hash, id, "654321"); err == nil {
t.Fatal("captcha retry reused")
}
id = f.knownCaptcha()
if err = f.s.verifyLoginCaptcha(f.ctx, "another-session", id, "654321"); err == nil {
t.Fatal("cross-session captcha accepted")
}
must(t, f.s.verifyLoginCaptcha(f.ctx, hash, id, "654321"))
if err = f.s.verifyLoginCaptcha(f.ctx, hash, id, "654321"); err == nil {
t.Fatal("captcha replay accepted")
}
id = f.knownCaptcha()
must(t, f.s.redis.PExpire(f.ctx, f.s.captchaKey(hash), -time.Second).Err())
if err = f.s.verifyLoginCaptcha(f.ctx, hash, id, "654321"); err == nil {
t.Fatal("expired captcha accepted")
}
}
func TestPhonePasswordRejectsBypassAndThrottles(t *testing.T) {
f := newLoginFixture(t)
input := PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!"}
w, _ := f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if w.Code != 401 || !strings.Contains(w.Body.String(), "CAPTCHA_INVALID") {
t.Fatal("captcha omission accepted")
}
input.CaptchaID = f.knownCaptcha()
input.CaptchaCode = "654321"
w, _ = f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if w.Code != 401 || !strings.Contains(w.Body.String(), "PASSWORD_INVALID") {
t.Fatal("unknown phone must use generic error")
}
w, _ = f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if !strings.Contains(w.Body.String(), "CAPTCHA_INVALID") {
t.Fatal("failed password reused captcha")
}
phoneHash := domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone)
must(t, f.s.redis.Set(f.ctx, "onboarding:limit:password:phone:"+phoneHash, 10, time.Minute).Err())
input.CaptchaID = f.knownCaptcha()
w, _ = f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if w.Code != 429 {
t.Fatal("password phone throttle missing")
}
}
func TestPhonePasswordExpiredSMSProofAndDisabledAccount(t *testing.T) {
f := newLoginFixture(t)
account, csrf := f.smsAccount()
_, err := f.s.pool.Exec(f.ctx, `UPDATE onboarding_account_session SET phone_verified_at=now()-interval '11 minutes' WHERE token_hash=$1`, digest([]byte(account.Value)))
must(t, err)
w, _ := f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, csrf, "")
if w.Code != 403 {
t.Fatal("expired SMS proof accepted", w.Body.String())
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE onboarding_account_session SET phone_verified_at=now() WHERE token_hash=$1`, digest([]byte(account.Value)))
must(t, err)
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE user_account SET status='disabled' WHERE oidc_issuer='urn:platform:onboarding:sms' AND oidc_subject=$1`, domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone))
must(t, err)
f.newLoginSession()
w, _ = f.request("POST", "/api/v1/web/login/password", PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!", CaptchaID: f.knownCaptcha(), CaptchaCode: "654321"}, f.cookie, f.csrf, "")
if w.Code != 403 {
t.Fatal("disabled account authenticated or re-registered", w.Body.String())
}
}
+10 -3
View File
@@ -48,8 +48,14 @@ func TestIntegrationConsumedHandoffCannotReplayReplacement(t *testing.T) {
}
type captureSMS struct {
code string
calls int
code string
calls int
purpose string
}
func (p *captureSMS) SendForPurpose(ctx context.Context, phone, code, id, purpose string) error {
p.purpose = purpose
return p.Send(ctx, phone, code, id)
}
func (p *captureSMS) Send(_ context.Context, _ string, code string, _ string) error {
@@ -75,7 +81,7 @@ func TestIntegrationSMSLoginRateAndOneTimeCode(t *testing.T) {
phone = phone[:i] + string('0'+(c-'a')) + phone[i+1:]
}
}
in := SMSSendInput{Phone: phone, Purpose: "LOGIN"}
in := SMSSendInput{Phone: phone, Purpose: "LOGIN", CaptchaID: f.knownCaptchaFor(f.web.Hash), CaptchaCode: "654321"}
id := uuid.NewString()
sent, err := f.s.SendSMS(f.ctx, f.web, id, in)
must(t, err)
@@ -84,6 +90,7 @@ func TestIntegrationSMSLoginRateAndOneTimeCode(t *testing.T) {
if sender.calls != 1 {
t.Fatal("SMS retry sent twice")
}
in.CaptchaID = f.knownCaptchaFor(f.web.Hash)
_, err = f.s.SendSMS(f.ctx, f.web, uuid.NewString(), in)
if err == nil {
t.Fatal("resend cooldown ignored")
+305
View File
@@ -0,0 +1,305 @@
package onboarding
import (
"context"
"encoding/json"
"errors"
"time"
"data-terminal-cloud/internal/hardwareprovision"
"data-terminal-cloud/internal/outbox"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Commit the execution fence before contacting the provider. A lost response or
// process crash is uncertain, never permission to register the same device again.
func (s *Service) processServerActivation(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
}
bound, err := voiceBound(ctx, tx, d)
if err != nil {
return err
}
if !bound || !deviceAllowed(d) || j.ID != in.ActivationID || j.Generation != d.Generation || in.Generation != d.Generation {
return nil
}
if j.Product != s.options.ProductKey || j.Instance != s.options.InstanceID {
return fail(409, "ACTIVATION_PRODUCT_CHANGED", "设备所属火山产品已变化,请核对")
}
if j.Registered != nil {
// Old device-side registrations may not have a server copy of the secret.
// Wait for migration; never re-register them to recover a missing secret.
if len(j.Secret) > 0 && j.RTCAppID != "" && oneOf(j.Status, "REGISTERED_OBSERVED", "READY") {
if err = s.ensureConfig(ctx, tx, d, &j); err != nil {
return err
}
}
return tx.Commit(ctx)
}
if err = s.expireAttempt(ctx, tx, d, &j); err != nil {
return err
}
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','SUCCESS_OBSERVED') AND stage IN ('SERVER_REGISTER','SDK_REGISTER'))`, j.ID).Scan(&busy); err != nil {
return err
}
if busy || !oneOf(j.Status, "NOT_STARTED", "RESERVED") {
return tx.Commit(ctx)
}
if s.options.Registrar == nil {
return errors.New("server registrar unavailable")
}
attempt := uuid.NewString()
_, 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,'SERVER','SERVER_REGISTER',$3,$4,'CONTACT_POSSIBLE',$5)`, attempt, j.ID, d.Generation, j.ConfigVersion, s.now().Add(30*time.Second))
if err != nil {
return err
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='CONTACT_POSSIBLE',current_attempt_id=$2::uuid,last_error_code=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, attempt)
if err != nil {
return err
}
if err = tx.Commit(ctx); err != nil {
return err
}
callCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
result, callErr := s.options.Registrar.Register(callCtx, hardwareprovision.RegisterInput{InstanceID: j.Instance, ProductKey: j.Product, DeviceName: j.Name})
cancel()
// Do not discard successful credentials just because the worker was cancelled.
saveCtx, saveCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer saveCancel()
return s.saveServerRegistration(saveCtx, d.ID, j.ID, attempt, result, callErr)
}
func voiceBound(ctx context.Context, tx pgx.Tx, d Device) (bool, error) {
var bound bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND generation=$2 AND unbound_at IS NULL) OR EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND generation=$2 AND released_at IS NULL)`, d.ID, d.Generation).Scan(&bound)
return bound, err
}
func (s *Service) saveServerRegistration(ctx context.Context, device, job, attempt string, result hardwareprovision.RegisterResult, callErr error) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, device, true)
if err != nil {
return err
}
j, err := readJob(ctx, tx, device)
if err != nil {
return err
}
if j.ID != job {
return errState
}
var status string
if err = tx.QueryRow(ctx, `SELECT status FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid AND executor='SERVER' FOR UPDATE`, attempt, job).Scan(&status); err != nil {
return err
}
if !oneOf(status, "CONTACT_POSSIBLE", "UNKNOWN") {
return tx.Commit(ctx)
}
good := callErr == nil && validVoiceSecret(result.DeviceSecret) && validRTCAppID(result.RTCAppID)
if !good {
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='UNKNOWN',completed_at=now() WHERE id=$1::uuid`, attempt)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='RECONCILE_REQUIRED',last_error_code='SERVER_RESULT_UNCERTAIN',version=version+1,updated_at=now() WHERE id=$1::uuid`, job)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// A rebind/unbind while the HTTP call ran does not erase the physical device's
// newly acquired identity. Save it, but only deliver it to a current binding.
j.Secret = s.crypt.seal([]byte(result.DeviceSecret), "provider:"+job)
j.RTCAppID = result.RTCAppID
now := s.now()
j.Registered = &now
bound, err := voiceBound(ctx, tx, d)
if err != nil {
return err
}
j.Status = "BLOCKED"
if bound && deviceAllowed(d) && j.Generation == d.Generation {
j.Status = "REGISTERED_OBSERVED"
}
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='SUCCESS_OBSERVED',completed_at=now() WHERE id=$1::uuid`, attempt)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET provider_secret_ciphertext=$2,provider_rtc_app_id=$3,sdk_registered_at=now(),engine_ready_at=NULL,config_ciphertext=NULL,config_digest=NULL,config_expires_at=NULL,config_ack_at=NULL,status=$4,last_error_code=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, job, j.Secret, j.RTCAppID, j.Status)
if err != nil {
return err
}
j.Config = nil
j.Expires = nil
j.Ack = nil
j.Engine = nil
if j.Status == "REGISTERED_OBSERVED" {
if err = s.ensureConfig(ctx, tx, d, &j); err != nil {
return err
}
}
return tx.Commit(ctx)
}
type DeviceVoiceCredential struct {
InstanceID string `json:"instance_id"`
ProductKey string `json:"product_key"`
DeviceName string `json:"device_name"`
DeviceSecret string `json:"device_secret"`
RTCAppID string `json:"RTCAppID"`
}
func validVoiceSecret(v string) bool { return len(v) >= 16 && len(v) <= 256 }
func validRTCAppID(v string) bool { return len(v) > 0 && len(v) <= 128 }
// Does not expose credentials through mini-program/account endpoints.
func (s *Service) voiceFields(ctx context.Context, tx pgx.Tx, d Device) (map[string]any, error) {
out := map[string]any{"voice_status": "preparing", "voice": nil}
if !s.options.ActivationEnabled {
out["voice_status"] = "paused"
return out, nil
}
j, err := readJob(ctx, tx, d.ID)
if errors.Is(err, pgx.ErrNoRows) {
return out, nil
}
if err != nil {
return nil, err
}
if j.Generation != d.Generation {
return out, nil
}
if j.Registered != nil && (len(j.Secret) == 0 || j.RTCAppID == "") {
out["voice_status"] = "migration_required"
return out, nil
}
if oneOf(j.Status, "BLOCKED", "RECONCILE_REQUIRED") {
out["voice_status"] = "reconcile_required"
return out, nil
}
if j.Registered == nil || len(j.Secret) == 0 || j.RTCAppID == "" {
return out, nil
}
secret, err := s.crypt.open(j.Secret, "provider:"+j.ID)
if err != nil {
return nil, err
}
if !validVoiceSecret(string(secret)) || !validRTCAppID(j.RTCAppID) {
return nil, errors.New("invalid stored provider credential")
}
out["voice_status"] = "ready"
out["voice"] = DeviceVoiceCredential{j.Instance, j.Product, j.Name, string(secret), j.RTCAppID}
return out, nil
}
// Callable immediately after binding, including before boot registration.
func (s *Service) VoiceConfig(ctx context.Context, d Device) (map[string]any, error) {
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
}
bound, err := voiceBound(ctx, tx, current)
if err != nil {
return nil, err
}
if !deviceAllowed(current) || !bound {
return nil, fail(409, "DEVICE_UNBOUND", "绑定错误:设备没有绑定人员")
}
out, err := s.voiceFields(ctx, tx, current)
if err != nil {
return nil, err
}
return out, tx.Commit(ctx)
}
// One-time import of credentials held only by old, already-registered firmware.
// Refuses unknown/unregistered devices and never overwrites a different secret.
func (s *Service) ImportVoiceCredential(ctx context.Context, d Device, in DeviceVoiceCredential) (map[string]any, error) {
if !validVoiceSecret(in.DeviceSecret) || !validRTCAppID(in.RTCAppID) {
return nil, fail(400, "INVALID_VOICE_CREDENTIAL", "语音凭据格式错误")
}
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
}
bound, err := voiceBound(ctx, tx, current)
if err != nil {
return nil, err
}
if !bound || !deviceAllowed(current) {
return nil, errAccess
}
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return nil, err
}
if j.Registered == nil || j.Generation != current.Generation || j.Instance != in.InstanceID || j.Product != in.ProductKey || j.Name != in.DeviceName {
return nil, fail(409, "VOICE_MIGRATION_NOT_ALLOWED", "仅可迁移本设备已完成注册的原凭据")
}
if len(j.Secret) > 0 {
old, e := s.crypt.open(j.Secret, "provider:"+j.ID)
if e != nil {
return nil, e
}
if !sameSecret(string(old), in.DeviceSecret) || (j.RTCAppID != "" && j.RTCAppID != in.RTCAppID) {
return nil, fail(409, "VOICE_CREDENTIAL_CONFLICT", "平台已有不同凭据,请核对")
}
if j.RTCAppID == in.RTCAppID {
return map[string]any{"accepted": true}, tx.Commit(ctx)
}
}
if !oneOf(j.Status, "REGISTERED_OBSERVED", "READY") {
return nil, errState
}
j.Secret = s.crypt.seal([]byte(in.DeviceSecret), "provider:"+j.ID)
j.RTCAppID = in.RTCAppID
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET provider_secret_ciphertext=$2,provider_rtc_app_id=$3,config_ciphertext=NULL,config_expires_at=NULL,config_ack_at=NULL,engine_ready_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, j.Secret, j.RTCAppID)
if err != nil {
return nil, err
}
j.Config = nil
j.Expires = nil
j.Ack = nil
if err = s.ensureConfig(ctx, tx, current, &j); err != nil {
return nil, err
}
return map[string]any{"accepted": true}, tx.Commit(ctx)
}
@@ -0,0 +1,295 @@
package onboarding
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
func serverFixture(t *testing.T) *fixture {
f := newFixture(t)
f.s.options.LegacyDeviceRegistration = false
return f
}
func TestServerVoiceBindingAndConfig(t *testing.T) {
f := serverFixture(t)
code, _ := f.deviceCall("voice-config", struct{}{}, "", true)
if code != 409 {
t.Fatalf("unbound config: %d", code)
}
token := f.bleToken()
rid := uuid.NewString()
out, err := f.s.BindBLE(f.ctx, f.d, rid, BLEBindInput{Token: token})
must(t, err)
if out["status"] != "success" || len(out) != 6 || out["device_secret"] == "" || out["RTCAppID"] != "test-app" {
t.Fatal("binding success must include all five credentials directly")
}
f.refresh()
must(t, f.s.ProcessActivation(f.ctx, f.event()))
must(t, f.s.ProcessActivation(f.ctx, f.event()))
if f.registrar.calls.Load() != 1 {
t.Fatal("duplicate registration")
}
out, err = f.s.BindBLE(f.ctx, f.d, rid, BLEBindInput{Token: token})
must(t, err)
if len(out) != 6 || out["RTCAppID"] != "test-app" || out["device_name"] != f.d.FactoryID || out["voice"] != nil {
t.Fatal("missing binding credentials")
}
j := f.job()
if j.Engine != nil || j.Ack != nil {
t.Fatal("server registration fabricated SDK initialization")
}
raw, err := f.s.crypt.open(j.Config, "config:"+j.ID)
must(t, err)
if strings.Contains(string(raw), "product_secret") || !strings.Contains(string(raw), "device_secret") {
t.Fatal("wrong platform config")
}
code, envelope := f.deviceCall("voice-runtime", VoiceRuntimeInput{BootID: f.d.BootID}, "", true)
if code != 200 {
t.Fatalf("voice runtime status %d", code)
}
data := envelope["data"].(map[string]any)
payload := data["command"].(map[string]any)["payload"].(map[string]any)
decoded, err := base64.RawURLEncoding.DecodeString(payload["config_b64"].(string))
must(t, err)
if strings.Contains(string(decoded), "product_secret") {
t.Fatal("product secret leaked to device")
}
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)
if f.job().Engine == nil || f.job().Ack == nil {
t.Fatal("device ack not saved")
}
mini, err := f.s.BLEStatus(f.ctx, f.user, BLEBindInput{Token: token})
must(t, err)
miniJSON, _ := json.Marshal(mini)
if strings.Contains(string(miniJSON), "secret") || mini["voice"] != nil {
t.Fatal("credentials exposed to mini program")
}
}
func TestBLEVoiceFailurePreservesBindingAndNeverReregisters(t *testing.T) {
f := serverFixture(t)
f.registrar.err = errors.New("provider response lost")
token := f.bleToken()
for i := 0; i < 2; i++ {
code, envelope := f.deviceCall("ble-bind", BLEBindInput{Token: token}, uuid.NewString(), true)
if code != 502 || envelope["code"] != "VOICE_REGISTRATION_FAILED" || envelope["ok"] != false {
t.Fatalf("expected explicit registration error, status=%d", code)
}
}
if f.registrar.calls.Load() != 1 {
t.Fatal("uncertain provider registration was repeated")
}
mini, err := f.s.BLEStatus(f.ctx, f.user, BLEBindInput{Token: token})
must(t, err)
if mini["status"] != "success" {
t.Fatal("registration failure erased completed binding")
}
}
func TestBLEVoiceWaitsForConcurrentWorkerAndReturnsHTTPFields(t *testing.T) {
f := serverFixture(t)
token := f.bleToken()
_, err := f.s.commitBLEBinding(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
f.refresh()
f.registrar.started, f.registrar.release = make(chan struct{}), make(chan struct{})
done := make(chan error, 1)
go func() { done <- f.s.ProcessActivation(f.ctx, f.event()) }()
select {
case <-f.registrar.started:
case <-time.After(5 * time.Second):
t.Fatal("provider not started")
}
// A short-lived HTTP caller times out without cancelling another worker.
ctx, cancel := context.WithTimeout(f.ctx, 100*time.Millisecond)
_, err = f.s.BindBLE(ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
cancel()
var apiErr *Error
if !errors.As(err, &apiErr) || apiErr.Code != "VOICE_REGISTRATION_TIMEOUT" {
t.Fatalf("expected timeout, got %v", err)
}
close(f.registrar.release)
must(t, <-done)
code, envelope := f.deviceCall("ble-bind", BLEBindInput{Token: token}, uuid.NewString(), true)
if code != 200 || envelope["ok"] != true {
t.Fatalf("binding HTTP retry status %d", code)
}
data := envelope["data"].(map[string]any)
for _, key := range []string{"instance_id", "product_key", "device_name", "device_secret", "RTCAppID"} {
if value, ok := data[key].(string); !ok || value == "" {
t.Fatalf("missing direct field %s", key)
}
}
if len(data) != 6 || f.registrar.calls.Load() != 1 {
t.Fatal("wrong response shape or duplicate provider call")
}
}
func TestBLEVoiceUnboundDuringRegistrationReturnsNoCredentials(t *testing.T) {
f := serverFixture(t)
token := f.bleToken()
f.registrar.started, f.registrar.release = make(chan struct{}), make(chan struct{})
done := make(chan error, 1)
go func() {
out, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
if out != nil {
done <- errors.New("returned credentials after unbind")
return
}
done <- err
}()
select {
case <-f.registrar.started:
case <-time.After(5 * time.Second):
t.Fatal("provider not started")
}
_, 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)
close(f.registrar.release)
err = <-done
var apiErr *Error
if !errors.As(err, &apiErr) || apiErr.Code != "BINDING_CHANGED" {
t.Fatalf("expected binding change, got %v", err)
}
if len(f.job().Secret) == 0 || f.registrar.calls.Load() != 1 {
t.Fatal("physical credential was lost")
}
}
func TestBLEVoicePausedReturnsErrorAndResumesSameToken(t *testing.T) {
f := serverFixture(t)
token := f.bleToken()
f.s.options.ActivationEnabled = false
_, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
var apiErr *Error
if !errors.As(err, &apiErr) || apiErr.Code != "VOICE_REGISTRATION_PAUSED" || f.registrar.calls.Load() != 0 {
t.Fatalf("expected paused, got %v", err)
}
f.s.options.ActivationEnabled = true
out, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
if out["RTCAppID"] != "test-app" || f.registrar.calls.Load() != 1 {
t.Fatal("resume failed")
}
}
func TestServerVoiceUncertainRegistrationNeverRetries(t *testing.T) {
f := serverFixture(t)
f.bound()
f.registrar.err = errors.New("timeout after request sent")
must(t, f.s.ProcessActivation(f.ctx, f.event()))
must(t, f.s.ProcessActivation(f.ctx, f.event()))
if f.registrar.calls.Load() != 1 || f.job().Status != "RECONCILE_REQUIRED" {
t.Fatal("uncertain registration retried")
}
out, err := f.s.VoiceConfig(f.ctx, f.d)
must(t, err)
if out["voice_status"] != "reconcile_required" || out["voice"] != nil {
t.Fatal("false ready")
}
}
func TestServerVoiceConcurrentWorkerAndUnbind(t *testing.T) {
f := serverFixture(t)
f.bound()
f.registrar.started = make(chan struct{})
f.registrar.release = make(chan struct{})
event := f.event()
done := make(chan error, 1)
go func() { done <- f.s.ProcessActivation(f.ctx, event) }()
select {
case <-f.registrar.started:
case <-time.After(5 * time.Second):
t.Fatal("worker did not register")
}
must(t, f.s.ProcessActivation(f.ctx, event))
_, 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)
close(f.registrar.release)
must(t, <-done)
if f.registrar.calls.Load() != 1 || len(f.job().Secret) == 0 {
t.Fatal("lost credentials or duplicate registration")
}
f.web = WebSession{} // The old setup web session was intentionally closed by unbind.
f.refresh()
_, err = f.s.VoiceConfig(f.ctx, f.d)
if err == nil {
t.Fatal("unbound device received credentials")
}
token := f.bleToken()
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
must(t, err)
f.refresh()
must(t, f.s.ProcessActivation(f.ctx, f.event()))
out, err := f.s.VoiceConfig(f.ctx, f.d)
must(t, err)
if out["voice_status"] != "ready" || f.registrar.calls.Load() != 1 {
t.Fatal("rebind did not reuse physical registration")
}
}
func TestServerVoiceLegacyCredentialMigration(t *testing.T) {
f := newFixture(t)
f.bound()
f.registerSDK()
f.s.options.LegacyDeviceRegistration = false
must(t, f.s.ProcessActivation(f.ctx, f.event()))
out, err := f.s.VoiceConfig(f.ctx, f.d)
must(t, err)
if out["voice_status"] != "migration_required" || f.registrar.calls.Load() != 0 {
t.Fatal("legacy credentials were re-registered")
}
j := f.job()
in := DeviceVoiceCredential{j.Instance, j.Product, j.Name, "fixture-old-device-secret-123456789", "old-rtc-app"}
_, err = f.s.ImportVoiceCredential(f.ctx, f.d, in)
must(t, err)
_, err = f.s.ImportVoiceCredential(f.ctx, f.d, in)
must(t, err)
in.DeviceSecret = "different-old-secret-123456789"
_, err = f.s.ImportVoiceCredential(f.ctx, f.d, in)
if err == nil {
t.Fatal("conflicting import overwrote credentials")
}
out, err = f.s.VoiceConfig(f.ctx, f.d)
must(t, err)
if out["voice_status"] != "ready" || f.registrar.calls.Load() != 0 {
t.Fatal("migration did not recover existing credentials")
}
}
func TestServerVoiceRejectUnregisteredImportAndDeviceExecution(t *testing.T) {
f := serverFixture(t)
f.bound()
j := f.job()
_, err := f.s.ImportVoiceCredential(f.ctx, f.d, DeviceVoiceCredential{j.Instance, j.Product, j.Name, "fake-secret-1234567890", "app"})
if err == nil {
t.Fatal("unregistered device imported credentials")
}
in := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
_, err = f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(in), in)
if err == nil {
t.Fatal("device registration permit issued")
}
}
func TestServerVoiceCrashFenceExpiresToReconciliation(t *testing.T) {
f := serverFixture(t)
f.bound()
j := f.job()
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO device_activation_attempt(id,activation_id,executor,stage,binding_generation,config_version,status,permit_expires_at) VALUES($1::uuid,$2::uuid,'SERVER','SERVER_REGISTER',$3,$4,'CONTACT_POSSIBLE',now()-interval '5 minutes')`, uuid.NewString(), j.ID, j.Generation, j.ConfigVersion)
must(t, err)
must(t, f.s.ProcessActivation(f.ctx, f.event()))
if f.registrar.calls.Load() != 0 || f.job().Status != "RECONCILE_REQUIRED" {
t.Fatal("crash fence triggered duplicate registration")
}
}
+29 -10
View File
@@ -2,6 +2,7 @@ package onboarding
import (
"context"
"data-terminal-cloud/internal/devicefleet"
"encoding/json"
"errors"
"fmt"
@@ -27,7 +28,8 @@ type Error struct {
Code, Message, Recovery string
}
func (e *Error) Error() string { return e.Code }
func (e *Error) Error() string { return e.Code }
func (e *Error) HTTPStatus() int { return e.Status }
func fail(status int, code, message string) *Error {
return &Error{Status: status, Code: code, Message: message, Recovery: "NONE"}
}
@@ -40,22 +42,30 @@ 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
SaaSCaptchaDisabled bool
Captcha CaptchaProvider
PauseClaims bool
TrustedProxyCIDRs []string
Enabled bool
MasterKey, EntryURL, ProviderMasterKey string
AllowLocalHTTP bool
TokenVerifier identity.TokenVerifier
LegacyVerifier LegacyVerifier
Registrar hardwareprovision.Registrar
// Compatibility harness only; production defaults to server registration.
LegacyDeviceRegistration bool
ActivationEnabled bool
InstanceID, ProductKey, ProductSecret, BotID, Transport, MinimumSDKVersion string
SMS SMSProvider
WeChat WeChatProvider
WeChatAppID, WeChatAppSecret string
SMSLoginEnabled bool
SelfSignupTenantIDs []string
Logger *slog.Logger
}
type Service struct {
Fleet *devicefleet.Service
operatorLogin bool
pool *pgxpool.Pool
redis *redis.Client
crypt cryptor
@@ -81,6 +91,9 @@ func NewService(pool *pgxpool.Pool, redisClient *redis.Client, options Options)
if options.Logger == nil {
options.Logger = slog.Default()
}
if options.WeChat == nil && options.WeChatAppID != "" && options.WeChatAppSecret != "" {
options.WeChat = newWeChatClient(options.WeChatAppID, options.WeChatAppSecret)
}
if options.ActivationEnabled && (len(options.ProductSecret) < 16 || options.InstanceID == "" || options.ProductKey == "" || options.BotID == "") {
return nil, errors.New("onboarding activation requires configured provider")
}
@@ -97,6 +110,12 @@ func NewService(pool *pgxpool.Pool, redisClient *redis.Client, options Options)
if options.ActivationEnabled && providerCrypt.aead == nil {
return nil, errors.New("onboarding activation requires provider credential master key")
}
if options.ActivationEnabled && options.Registrar == nil && !options.LegacyDeviceRegistration {
options.Registrar, err = hardwareprovision.NewHTTPRegistrar("", options.ProductSecret, 20*time.Second)
if err != nil {
return nil, err
}
}
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 }
@@ -153,7 +172,7 @@ func (s *Service) state(ctx context.Context, tx pgx.Tx, d Device) (State, error)
}
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)
err = tx.QueryRow(ctx, `SELECT (EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL) OR EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND released_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
}
+110 -31
View File
@@ -24,6 +24,10 @@ import (
type SMSProvider interface {
Send(context.Context, string, string, string) error
}
type purposeSMSProvider interface {
SendForPurpose(context.Context, string, string, string, string) error
}
type HTTPSMSProvider struct {
endpoint, token string
client *http.Client
@@ -62,24 +66,47 @@ func (p *HTTPSMSProvider) Send(ctx context.Context, phone, code, id string) erro
var phonePattern = regexp.MustCompile(`^\+[1-9][0-9]{7,14}$`)
var smsCodePattern = regexp.MustCompile(`^[0-9]{6}$`)
const smsCodeLifetime = 5 * time.Minute
type SMSSendInput struct {
Phone string `json:"phone_e164"`
Purpose string `json:"purpose"`
CaptchaTicket string `json:"captcha_ticket,omitempty"`
CaptchaRandstr string `json:"captcha_randstr,omitempty"`
Phone string `json:"phone_e164"`
Purpose string `json:"purpose"`
CaptchaID string `json:"captcha_id"`
CaptchaCode string `json:"captcha_code"`
}
func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, in SMSSendInput) (map[string]any, error) {
return s.sendSMS(ctx, w.Hash, requestID, in, "onboarding_sms_challenge", func(tx pgx.Tx) error {
d, err := readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return err
}
return s.checkWebActor(ctx, tx, w.Hash, d)
})
}
// table is an internal constant, never supplied by a request. Both login entry
// points share phone limits, code hashing, delivery idempotency and attempt caps.
func (s *Service) sendSMS(ctx context.Context, sessionHash, requestID string, in SMSSendInput, table string, check func(pgx.Tx) error) (map[string]any, error) {
if !s.options.SMSLoginEnabled {
return nil, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放,请使用账号登录")
return nil, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放,请稍后重试")
}
if !uuidV4(requestID) || !phonePattern.MatchString(in.Phone) || in.Purpose != "LOGIN" {
if !uuidV4(requestID) || !phonePattern.MatchString(in.Phone) {
return nil, fail(400, "VALIDATION_FAILED", "请使用包含国家区号的手机号码")
}
// These routes authenticate an account (including the existing password
// recovery flow). Phone changes require their own authenticated workflow.
if in.Purpose != SMSPurposeLogin && in.Purpose != SMSPurposeChangePassword {
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 {
if err := s.limit(ctx, "sms:session:"+sessionHash, 10, time.Hour); err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
@@ -87,22 +114,18 @@ func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, i
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 {
if err = check(tx); 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 id, status, stored, storedPurpose 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)
err = tx.QueryRow(ctx, `SELECT id::text,status,phone_digest,expires_at,purpose FROM `+table+` WHERE session_hash=$1 AND request_id=$2::uuid`, sessionHash, requestID).Scan(&id, &status, &stored, &expires, &storedPurpose)
if err == nil {
if stored != phoneHash {
return nil, fail(409, "REQUEST_BODY_CONFLICT", "请求号已用于其他手机号码")
if stored != phoneHash || storedPurpose != in.Purpose {
return nil, fail(409, "REQUEST_BODY_CONFLICT", "请求号已用于其他手机号码或短信用途")
}
if status != "SENT" || !expires.After(s.now()) {
return nil, fail(409, "SMS_SEND_UNCERTAIN", "请等待重发间隔后重新申请验证码")
@@ -112,35 +135,59 @@ func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, i
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
// Exact delivery retries above return only the previous result. Every new
// delivery must consume a fresh challenge bound to this browser session.
if err = s.verifyLoginCaptcha(ctx, sessionHash, in.CaptchaID, in.CaptchaCode, in.CaptchaTicket, in.CaptchaRandstr); err != nil {
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)
err = tx.QueryRow(ctx, `SELECT count(*) FILTER(WHERE created_at>now()-interval '60 seconds'),count(*) FROM (
SELECT created_at FROM onboarding_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'
UNION ALL SELECT created_at FROM customer_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'
UNION ALL SELECT created_at FROM operator_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'
) sends`, phoneHash).Scan(&recent, &hourCount)
if err != nil {
return nil, err
}
if recent > 0 || hourCount >= 5 {
return nil, fail(429, "SMS_RATE_LIMITED", "发送过于频繁,请稍后再试")
}
templatePurpose := in.Purpose
if in.Purpose == SMSPurposeLogin && !s.operatorLogin {
var registered bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM `+s.phoneCredentialTable()+` WHERE phone_digest=$1)`, phoneHash).Scan(&registered); err != nil {
return nil, err
}
if !registered {
templatePurpose = SMSPurposeRegister
}
}
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)
expires = s.now().Add(smsCodeLifetime)
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)
_, err = tx.Exec(ctx, `INSERT INTO `+table+`(id,session_hash,phone_digest,phone_ciphertext,code_hash,request_id,expires_at,purpose) VALUES($1::uuid,$2,$3,$4,$5,$6::uuid,$7,$8)`, id, sessionHash, phoneHash, s.crypt.seal([]byte(in.Phone), "sms-phone:"+id), codeHash, requestID, expires, in.Purpose)
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)
var sendErr error
if sender, ok := s.options.SMS.(purposeSMSProvider); ok {
sendErr = sender.SendForPurpose(ctx, in.Phone, code, id, templatePurpose)
} else {
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 {
if _, err = s.pool.Exec(ctx, `UPDATE `+table+` SET status=$2 WHERE id=$1::uuid AND status='SENDING'`, id, status); err != nil {
return nil, err
}
if sendErr != nil {
@@ -159,6 +206,20 @@ type SMSVerifyInput struct {
}
func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput) (BrowserCredentials, error) {
var d Device
return s.verifySMS(ctx, w.Hash, in, "onboarding_sms_challenge", func(tx pgx.Tx) error {
var err error
d, err = readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return err
}
return s.checkWebActor(ctx, tx, w.Hash, d)
}, func(tx pgx.Tx, p identity.Principal) (BrowserCredentials, error) {
return s.attachPrincipal(ctx, tx, w, d, p, true)
})
}
func (s *Service) verifySMS(ctx context.Context, sessionHash string, in SMSVerifyInput, table string, check func(pgx.Tx) error, attach func(pgx.Tx, identity.Principal) (BrowserCredentials, error)) (BrowserCredentials, error) {
var out BrowserCredentials
if !s.options.SMSLoginEnabled {
return out, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放")
@@ -171,18 +232,14 @@ func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput
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 {
if err = check(tx); 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)
err = tx.QueryRow(ctx, `SELECT code_hash,phone_digest,phone_ciphertext,status,attempts,expires_at FROM `+table+` WHERE id=$1::uuid AND session_hash=$2 FOR UPDATE`, in.ChallengeID, sessionHash).Scan(&expected, &phoneHash, &encrypted, &status, &attempts, &expires)
if errors.Is(err, pgx.ErrNoRows) {
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
}
@@ -193,7 +250,7 @@ func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput
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 {
if _, err = tx.Exec(ctx, `UPDATE `+table+` SET attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID); err != nil {
return out, err
}
if err = tx.Commit(ctx); err != nil {
@@ -207,11 +264,33 @@ func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput
}
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)
// Resolve both login methods to the same verified phone identity.
if !s.operatorLogin {
_, err = tx.Exec(ctx, `INSERT INTO `+s.phoneCredentialTable()+`(phone_digest,phone_masked,issuer,subject) VALUES($1,$2,$3,$4) ON CONFLICT(phone_digest) DO NOTHING`, phoneHash, string(phone[:len(phone)-8])+"****"+string(phone[len(phone)-4:]), p.Issuer, p.Subject)
if err != nil {
return out, err
}
}
err = tx.QueryRow(ctx, `SELECT issuer,subject FROM `+s.phoneCredentialTable()+` WHERE phone_digest=$1 FOR SHARE`, phoneHash).Scan(&p.Issuer, &p.Subject)
if errors.Is(err, pgx.ErrNoRows) && s.operatorLogin {
return out, fail(403, "OPERATOR_ACCOUNT_UNAVAILABLE", "该手机号尚未开通管理权限,请联系管理员")
}
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)
out, err = attach(tx, p)
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE `+s.accountSessionTable()+` SET phone_digest=$2,phone_verified_at=$3 WHERE token_hash=$1`, digest([]byte(out.AccountToken)), phoneHash, s.now())
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE `+table+` SET status='CONSUMED',consumed_at=now(),attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID)
if err != nil {
return out, err
}
out.TrustedDeviceToken, err = s.rememberLoginDevice(ctx, tx, phoneHash)
if err != nil {
return out, err
}
@@ -223,11 +302,11 @@ func (s *Service) AccountPrincipal(ctx context.Context, token, csrf string, writ
var p identity.Principal
var expected string
if !validB64(token, 32) {
return p, errAccess
return p, fail(401, "LOGIN_REQUIRED", "登录已失效,请重新登录")
}
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)
err := s.pool.QueryRow(ctx, `SELECT ua.oidc_issuer,ua.oidc_subject,COALESCE(ua.display_name,''),ss.csrf_hash FROM `+s.accountSessionTable()+` ss JOIN user_account ua ON ua.id=ss.user_id AND ua.status='active' AND `+s.accountPredicate()+` 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
return p, fail(401, "LOGIN_REQUIRED", "登录已失效,请重新登录")
}
if err != nil {
return p, err
+69
View File
@@ -0,0 +1,69 @@
package onboarding
import (
"github.com/google/uuid"
"testing"
"time"
)
func TestSMSCaptchaRequiredBeforeDelivery(t *testing.T) {
f := newLoginFixture(t)
input := SMSSendInput{Phone: f.phone, Purpose: "LOGIN"}
send := func(id string) int {
w, _ := f.request("POST", "/api/v1/web/login/sms/send", input, f.cookie, f.csrf, id)
return w.Code
}
if send("") != 401 || f.sender.calls != 0 {
t.Fatal("missing image challenge sent SMS")
}
input = f.smsInput()
input.CaptchaCode = "000000"
if send("") != 401 || f.sender.calls != 0 {
t.Fatal("wrong image answer sent SMS")
}
input.CaptchaCode = "654321"
if send("") != 401 {
t.Fatal("failed image answer was reusable")
}
input.CaptchaID = f.knownCaptchaFor("another-browser")
if send("") != 401 {
t.Fatal("foreign browser image accepted")
}
input = f.smsInput()
must(t, f.s.redis.Expire(f.ctx, f.s.captchaKey(digest([]byte(f.cookie.Value))), -time.Second).Err())
if send("") != 401 {
t.Fatal("expired image accepted")
}
input = f.smsInput()
id := uuid.NewString()
if send(id) != 200 || f.sender.calls != 1 {
t.Fatal("valid image did not send SMS")
}
if send(id) != 200 || f.sender.calls != 1 {
t.Fatal("exact retry delivered twice")
}
if send("") != 401 || f.sender.calls != 1 {
t.Fatal("image replay delivered again")
}
}
func TestSetupSMSCaptchaIsBoundToSetupSession(t *testing.T) {
f := newLoginFixture(t)
image, err := f.s.webCaptcha(f.ctx, f.web)
must(t, err)
if image["image"] == nil {
t.Fatal("missing setup captcha")
}
in := SMSSendInput{Phone: f.phone, Purpose: "LOGIN"}
if _, err = f.s.SendSMS(f.ctx, f.web, uuid.NewString(), in); err == nil || f.sender.calls != 0 {
t.Fatal("setup SMS bypassed image")
}
in.CaptchaID, in.CaptchaCode = f.knownCaptcha(), "654321"
if _, err = f.s.SendSMS(f.ctx, f.web, uuid.NewString(), in); err == nil {
t.Fatal("login captcha crossed into setup")
}
in.CaptchaID = f.knownCaptchaFor(f.web.Hash)
if _, err = f.s.SendSMS(f.ctx, f.web, uuid.NewString(), in); err != nil || f.sender.calls != 1 {
t.Fatal("setup captcha rejected", err)
}
}
+69
View File
@@ -0,0 +1,69 @@
package onboarding
import (
"testing"
"github.com/google/uuid"
)
func TestSMSLoginSelectsRegistrationOrExistingAccountTemplate(t *testing.T) {
for _, registered := range []bool{false, true} {
name := "register"
if registered {
name = "login"
}
t.Run(name, func(t *testing.T) {
f := newLoginFixture(t)
if registered {
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO customer_phone_credential(phone_digest,phone_masked,issuer,subject) VALUES($1,$2,$3,$4)`, domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone), "+86138****0000", f.user.Issuer, f.user.Subject)
must(t, err)
}
f.send()
want := SMSPurposeRegister
if registered {
want = SMSPurposeLogin
}
if f.sender.purpose != want {
t.Fatalf("expected %s, got %s", want, f.sender.purpose)
}
})
}
}
func TestSMSPasswordPurposeAndIdempotencyAreBoundTogether(t *testing.T) {
f := newLoginFixture(t)
in := f.smsInput()
in.Purpose = SMSPurposeChangePassword
id := uuid.NewString()
w, first := f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, id)
if w.Code != 200 || f.sender.purpose != SMSPurposeChangePassword {
t.Fatal("wrong password template", w.Body.String())
}
w, again := f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, id)
if w.Code != 200 || first["sms_challenge_id"] != again["sms_challenge_id"] || f.sender.calls != 1 {
t.Fatal("replay sent a second message", w.Body.String())
}
in.Purpose = SMSPurposeLogin
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, id)
if w.Code != 409 || f.sender.calls != 1 {
t.Fatal("request ID reused across purposes", w.Body.String())
}
}
func TestSMSLoginRejectsPhoneChangePurpose(t *testing.T) {
f := newLoginFixture(t)
in := f.smsInput()
in.Purpose = SMSPurposeChangePhone
w, _ := f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, "")
if w.Code != 400 || f.sender.calls != 0 {
t.Fatal("phone change accepted by login", w.Body.String())
}
}
func TestSMSOperatorAlwaysUsesLoginTemplate(t *testing.T) {
f := newOperatorLoginFixture(t)
f.send()
if f.sender.purpose != SMSPurposeLogin {
t.Fatal("unprovisioned operator received a registration message")
}
}
+105
View File
@@ -0,0 +1,105 @@
package onboarding
import (
"context"
"data-terminal-cloud/internal/config"
captcha "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha/v20190722"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
"net"
"strconv"
"strings"
)
type CaptchaProvider interface {
AppID(client string) string
Verify(ctx context.Context, client, ticket, randstr, ip string) error
}
type captchaAPI interface {
DescribeCaptchaResultWithContext(context.Context, *captcha.DescribeCaptchaResultRequest) (*captcha.DescribeCaptchaResultResponse, error)
DescribeCaptchaMiniResultWithContext(context.Context, *captcha.DescribeCaptchaMiniResultRequest) (*captcha.DescribeCaptchaMiniResultResponse, error)
}
type tencentCaptchaProvider struct {
api captchaAPI
config config.TencentCaptchaConfig
}
func NewTencentCaptchaProvider(c config.TencentCaptchaConfig) (CaptchaProvider, error) {
if err := c.Validate(); err != nil {
return nil, err
}
if c.SecretID == "" {
return nil, nil
}
p := profile.NewClientProfile()
p.HttpProfile.Endpoint = "captcha.tencentcloudapi.com"
p.HttpProfile.ReqTimeout = 8
p.NetworkFailureMaxRetries = 0
p.RateLimitExceededMaxRetries = 0
p.UnsafeRetryOnConnectionFailure = false
api, err := captcha.NewClient(common.NewCredential(c.SecretID, c.SecretKey), "", p)
if err != nil {
return nil, err
}
return &tencentCaptchaProvider{api: api, config: c}, nil
}
func (p *tencentCaptchaProvider) clientConfig(client string) config.CaptchaClientConfig {
if client == "mini_program" {
return p.config.Mini
}
if client == "saas" {
return p.config.SaaS
}
return config.CaptchaClientConfig{}
}
func (p *tencentCaptchaProvider) AppID(client string) string { return p.clientConfig(client).AppID }
func (p *tencentCaptchaProvider) Verify(ctx context.Context, client, ticket, randstr, ip string) error {
c := p.clientConfig(client)
if c.AppID == "" {
return captchaUnavailable()
}
if ticket == "" || len(ticket) > 4096 || strings.HasPrefix(ticket, "trerror") || net.ParseIP(ip) == nil {
return captchaInvalid()
}
id, _ := strconv.ParseUint(c.AppID, 10, 64)
if client == "mini_program" {
r := captcha.NewDescribeCaptchaMiniResultRequest()
r.CaptchaType = common.Uint64Ptr(9)
r.CaptchaAppId = &id
r.AppSecretKey = &c.AppSecretKey
r.Ticket = &ticket
r.UserIp = &ip
out, err := p.api.DescribeCaptchaMiniResultWithContext(ctx, r)
if err != nil {
return captchaUnavailable()
}
if out == nil || out.Response == nil || out.Response.CaptchaCode == nil || *out.Response.CaptchaCode != 1 {
return captchaInvalid()
}
return nil
}
if randstr == "" || len(randstr) > 256 {
return captchaInvalid()
}
r := captcha.NewDescribeCaptchaResultRequest()
r.CaptchaType = common.Uint64Ptr(9)
r.CaptchaAppId = &id
r.AppSecretKey = &c.AppSecretKey
r.Ticket = &ticket
r.Randstr = &randstr
r.UserIp = &ip
out, err := p.api.DescribeCaptchaResultWithContext(ctx, r)
if err != nil {
return captchaUnavailable()
}
if out == nil || out.Response == nil || out.Response.CaptchaCode == nil || *out.Response.CaptchaCode != 1 || (out.Response.EvilLevel != nil && *out.Response.EvilLevel != 0) {
return captchaInvalid()
}
return nil
}
func captchaInvalid() error {
return fail(401, "CAPTCHA_INVALID", "人机验证失败或已过期,请重新验证")
}
func captchaUnavailable() error {
return fail(503, "CAPTCHA_UNAVAILABLE", "人机验证暂不可用,请稍后重试")
}
@@ -0,0 +1,84 @@
package onboarding
import (
"context"
"data-terminal-cloud/internal/config"
"errors"
captcha "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha/v20190722"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"net/http/httptest"
"strings"
"testing"
)
type fakeCaptchaAPI struct {
web, mini int
result int64
err error
t *testing.T
}
func (f *fakeCaptchaAPI) DescribeCaptchaResultWithContext(_ context.Context, r *captcha.DescribeCaptchaResultRequest) (*captcha.DescribeCaptchaResultResponse, error) {
f.web++
if *r.CaptchaAppId != 123 || *r.AppSecretKey != "web-secret-123456" || *r.Randstr != "random" || *r.CaptchaType != 9 {
f.t.Fatal("wrong web request")
}
return &captcha.DescribeCaptchaResultResponse{Response: &captcha.DescribeCaptchaResultResponseParams{CaptchaCode: common.Int64Ptr(f.result)}}, f.err
}
func (f *fakeCaptchaAPI) DescribeCaptchaMiniResultWithContext(_ context.Context, r *captcha.DescribeCaptchaMiniResultRequest) (*captcha.DescribeCaptchaMiniResultResponse, error) {
f.mini++
if *r.CaptchaAppId != 456 || *r.AppSecretKey != "mini-secret-12345" || *r.CaptchaType != 9 {
f.t.Fatal("wrong mini request")
}
return &captcha.DescribeCaptchaMiniResultResponse{Response: &captcha.DescribeCaptchaMiniResultResponseParams{CaptchaCode: common.Int64Ptr(f.result)}}, f.err
}
func TestTencentCaptchaProviderSeparatesClientsAndFailsClosed(t *testing.T) {
f := &fakeCaptchaAPI{t: t, result: 1}
p := &tencentCaptchaProvider{api: f, config: config.TencentCaptchaConfig{SaaS: config.CaptchaClientConfig{AppID: "123", AppSecretKey: "web-secret-123456"}, Mini: config.CaptchaClientConfig{AppID: "456", AppSecretKey: "mini-secret-12345"}}}
ctx := context.Background()
if err := p.Verify(ctx, "saas", "ticket", "random", "203.0.113.1"); err != nil {
t.Fatal(err)
}
if err := p.Verify(ctx, "mini_program", "ticket", "", "203.0.113.1"); err != nil {
t.Fatal(err)
}
if f.web != 1 || f.mini != 1 {
t.Fatal("wrong API")
}
for _, code := range []int64{0, 7, 8, 9, 21, 100} {
f.result = code
if p.Verify(ctx, "saas", "ticket", "random", "203.0.113.1") == nil {
t.Fatal("rejection accepted", code)
}
}
f.result = 1
f.err = errors.New("contains-secret-value")
if err := p.Verify(ctx, "saas", "ticket", "random", "203.0.113.1"); err == nil || strings.Contains(err.Error(), "secret-value") {
t.Fatal("error must be closed and sanitized")
}
before := f.web
for _, ticket := range []string{"", "trerror_123", strings.Repeat("x", 4097)} {
if p.Verify(ctx, "saas", ticket, "random", "203.0.113.1") == nil {
t.Fatal("bad ticket accepted")
}
}
if f.web != before {
t.Fatal("bad ticket caused API call")
}
}
func TestCaptchaClientIPTrustBoundary(t *testing.T) {
s := &Service{options: Options{TrustedProxyCIDRs: []string{"172.18.0.0/16"}}}
for _, tc := range []struct{ remote, forwarded, want string }{
{"203.0.113.9:123", "1.1.1.1", "203.0.113.9"},
{"172.18.0.4:123", "1.1.1.1, 203.0.113.9", "203.0.113.9"},
{"172.18.0.4:123", "203.0.113.9, 172.18.0.3", "203.0.113.9"},
{"[2001:db8::1]:123", "1.1.1.1", "2001:db8::1"},
} {
r := httptest.NewRequest("POST", "https://example.test", nil)
r.RemoteAddr = tc.remote
r.Header.Set("X-Forwarded-For", tc.forwarded)
if got := s.clientIP(r); got != tc.want {
t.Fatalf("ip %s want %s", got, tc.want)
}
}
}
+134
View File
@@ -0,0 +1,134 @@
package onboarding
import (
"context"
"errors"
"regexp"
"strconv"
"strings"
"time"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
sms "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
)
// Tencent SMS accepts a request before the carrier delivers it. A failed or
// ambiguous call is never retried here: the challenge remains subject to the
// existing resend limits and cannot cause an accidental duplicate charge.
type tencentSMSSender interface {
SendSmsWithContext(context.Context, *sms.SendSmsRequest) (*sms.SendSmsResponse, error)
}
type TencentSMSProvider struct {
parameters map[string][]string
client tencentSMSSender
appID, signName string
templates map[string]string
}
var mainlandPhonePattern = regexp.MustCompile(`^\+86[1][3-9][0-9]{9}$`)
var smsTemplateIDPattern = regexp.MustCompile(`^[0-9]+$`)
const (
SMSPurposeLogin = "LOGIN"
SMSPurposeRegister = "REGISTER"
SMSPurposeChangePassword = "CHANGE_PASSWORD"
SMSPurposeChangePhone = "CHANGE_PHONE"
)
var smsTemplateKeys = map[string]string{
SMSPurposeLogin: "login", SMSPurposeRegister: "register",
SMSPurposeChangePassword: "change_password", SMSPurposeChangePhone: "change_phone",
}
func NewTencentSMSProvider(secretID, secretKey, appID, signName, templateID string) (SMSProvider, error) {
// Keep the original single-template environment configuration compatible.
return NewTencentSMSProviderWithTemplates(secretID, secretKey, appID, signName, map[string]string{
"login": templateID, "register": templateID, "change_password": templateID, "change_phone": templateID,
})
}
func NewTencentSMSProviderWithTemplates(secretID, secretKey, appID, signName string, templates map[string]string, parameterOptions ...map[string][]string) (SMSProvider, error) {
if strings.TrimSpace(secretID) == "" || strings.TrimSpace(secretKey) == "" ||
!smsTemplateIDPattern.MatchString(appID) || strings.TrimSpace(signName) == "" {
return nil, errors.New("Tencent SMS requires SecretId, SecretKey, numeric SDK AppID and approved sign name")
}
copyTemplates := make(map[string]string, len(smsTemplateKeys))
parameters := make(map[string][]string, len(smsTemplateKeys))
for _, key := range smsTemplateKeys {
if !smsTemplateIDPattern.MatchString(templates[key]) {
return nil, errors.New("Tencent SMS requires numeric login, register, change_password and change_phone template IDs")
}
copyTemplates[key] = templates[key]
parameters[key] = []string{"code"}
}
if len(parameterOptions) > 1 {
return nil, errors.New("Tencent SMS accepts one template parameter configuration")
}
if len(parameterOptions) == 1 {
for key, values := range parameterOptions[0] {
if _, ok := copyTemplates[key]; !ok {
return nil, errors.New("Tencent SMS template parameters have an unknown purpose")
}
seen := map[string]bool{}
for _, value := range values {
if (value != "code" && value != "expires_minutes") || seen[value] {
return nil, errors.New("Tencent SMS template parameters must be code and optionally expires_minutes")
}
seen[value] = true
}
if !seen["code"] {
return nil, errors.New("Tencent SMS template parameters must include code")
}
parameters[key] = append([]string(nil), values...)
}
}
clientProfile := profile.NewClientProfile()
clientProfile.HttpProfile.Endpoint = "sms.tencentcloudapi.com"
clientProfile.HttpProfile.ReqTimeout = 10
clientProfile.NetworkFailureMaxRetries = 0
clientProfile.RateLimitExceededMaxRetries = 0
clientProfile.UnsafeRetryOnConnectionFailure = false
client, err := sms.NewClient(common.NewCredential(secretID, secretKey), "ap-guangzhou", clientProfile)
if err != nil {
return nil, err
}
return &TencentSMSProvider{client: client, appID: appID, signName: signName, templates: copyTemplates, parameters: parameters}, nil
}
func (p *TencentSMSProvider) Send(ctx context.Context, phone, code, id string) error {
return p.SendForPurpose(ctx, phone, code, id, SMSPurposeLogin)
}
func (p *TencentSMSProvider) SendForPurpose(ctx context.Context, phone, code, id, purpose string) error {
templateID := p.templates[smsTemplateKeys[purpose]]
if templateID == "" || !mainlandPhonePattern.MatchString(phone) || !smsCodePattern.MatchString(code) || !uuidV4(id) {
return errors.New("SMS_GATEWAY_REJECTED")
}
req := sms.NewSendSmsRequest()
req.PhoneNumberSet = common.StringPtrs([]string{phone})
req.SmsSdkAppId = common.StringPtr(p.appID)
req.SignName = common.StringPtr(p.signName)
req.TemplateId = common.StringPtr(templateID)
values := make([]string, 0, len(p.parameters[smsTemplateKeys[purpose]]))
for _, name := range p.parameters[smsTemplateKeys[purpose]] {
if name == "code" {
values = append(values, code)
} else {
values = append(values, strconv.Itoa(int(smsCodeLifetime/time.Minute)))
}
}
req.TemplateParamSet = common.StringPtrs(values)
req.SessionContext = common.StringPtr(id)
result, err := p.client.SendSmsWithContext(ctx, req)
if err != nil {
return errors.New("SMS_GATEWAY_UNCERTAIN")
}
if result == nil || result.Response == nil || len(result.Response.SendStatusSet) != 1 ||
result.Response.SendStatusSet[0] == nil || result.Response.SendStatusSet[0].Code == nil ||
*result.Response.SendStatusSet[0].Code != "Ok" {
return errors.New("SMS_GATEWAY_REJECTED")
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package onboarding
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
sms "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
)
type fakeTencentSMSSender struct {
request *sms.SendSmsRequest
result *sms.SendSmsResponse
err error
calls int
}
func (f *fakeTencentSMSSender) SendSmsWithContext(_ context.Context, request *sms.SendSmsRequest) (*sms.SendSmsResponse, error) {
f.calls++
f.request = request
return f.result, f.err
}
func TestTencentSMSProviderUsesApprovedTemplateAndChecksPerPhoneStatus(t *testing.T) {
provider, err := NewTencentSMSProvider("secret-id", "secret-key", "1400006666", "安芯伴", "123456")
if err != nil {
t.Fatal(err)
}
p := provider.(*TencentSMSProvider)
fake := &fakeTencentSMSSender{result: &sms.SendSmsResponse{Response: &sms.SendSmsResponseParams{SendStatusSet: []*sms.SendStatus{{Code: common.StringPtr("Ok")}}}}}
p.client = fake
id := uuid.NewString()
if err = p.Send(context.Background(), "+8613800000000", "123456", id); err != nil {
t.Fatal(err)
}
r := fake.request
if fake.calls != 1 || *r.SmsSdkAppId != "1400006666" || *r.SignName != "安芯伴" || *r.TemplateId != "123456" ||
len(r.PhoneNumberSet) != 1 || *r.PhoneNumberSet[0] != "+8613800000000" ||
len(r.TemplateParamSet) != 1 || *r.TemplateParamSet[0] != "123456" || *r.SessionContext != id {
t.Fatal("Tencent SMS request does not match approved OTP template")
}
fake.result.Response.SendStatusSet[0].Code = common.StringPtr("FailedOperation")
if err = p.Send(context.Background(), "+8613800000000", "123456", uuid.NewString()); err == nil {
t.Fatal("provider accepted a failed per-phone send status")
}
fake.err = errors.New("transport timeout")
if err = p.Send(context.Background(), "+8613800000000", "123456", uuid.NewString()); err == nil {
t.Fatal("provider accepted an uncertain send")
}
before := fake.calls
if err = p.Send(context.Background(), "+15551234567", "123456", uuid.NewString()); err == nil || fake.calls != before {
t.Fatal("domestic template sent to an international phone")
}
}
func TestTencentSMSProviderRejectsMissingConfiguration(t *testing.T) {
for _, config := range [][5]string{
{"", "key", "1400006666", "安芯伴", "123456"},
{"id", "", "1400006666", "安芯伴", "123456"},
{"id", "key", "not-numeric", "安芯伴", "123456"},
{"id", "key", "1400006666", "", "123456"},
{"id", "key", "1400006666", "安芯伴", ""},
} {
if _, err := NewTencentSMSProvider(config[0], config[1], config[2], config[3], config[4]); err == nil {
t.Fatal("invalid Tencent SMS configuration accepted")
}
}
}
func TestTencentSMSRoutesEachPurposeToItsOwnTemplate(t *testing.T) {
templates := map[string]string{"login": "101", "register": "102", "change_password": "103", "change_phone": "104"}
provider, err := NewTencentSMSProviderWithTemplates("id", "key", "1400006666", "安芯伴", templates)
if err != nil {
t.Fatal(err)
}
p := provider.(*TencentSMSProvider)
fake := &fakeTencentSMSSender{result: &sms.SendSmsResponse{Response: &sms.SendSmsResponseParams{SendStatusSet: []*sms.SendStatus{{Code: common.StringPtr("Ok")}}}}}
p.client = fake
for purpose, want := range map[string]string{SMSPurposeLogin: "101", SMSPurposeRegister: "102", SMSPurposeChangePassword: "103", SMSPurposeChangePhone: "104"} {
if err := p.SendForPurpose(context.Background(), "+8613800000000", "123456", uuid.NewString(), purpose); err != nil || *fake.request.TemplateId != want {
t.Fatalf("purpose %s did not select its own template: %v", purpose, err)
}
}
if err := p.SendForPurpose(context.Background(), "+8613800000000", "123456", uuid.NewString(), "UNKNOWN"); err == nil || fake.calls != 4 {
t.Fatal("unknown purpose sent a message")
}
delete(templates, "change_phone")
if _, err := NewTencentSMSProviderWithTemplates("id", "key", "1400006666", "安芯伴", templates); err == nil {
t.Fatal("missing purpose silently fell back to another template")
}
}
func TestTencentSMSRegistrationIncludesConfiguredExpiryParameter(t *testing.T) {
templates := map[string]string{"login": "101", "register": "102", "change_password": "103", "change_phone": "104"}
provider, err := NewTencentSMSProviderWithTemplates("id", "key", "1400006666", "安芯伴", templates, map[string][]string{"register": {"code", "expires_minutes"}})
if err != nil {
t.Fatal(err)
}
p := provider.(*TencentSMSProvider)
fake := &fakeTencentSMSSender{result: &sms.SendSmsResponse{Response: &sms.SendSmsResponseParams{SendStatusSet: []*sms.SendStatus{{Code: common.StringPtr("Ok")}}}}}
p.client = fake
if err := p.SendForPurpose(context.Background(), "+8613800000000", "123456", uuid.NewString(), SMSPurposeRegister); err != nil {
t.Fatal(err)
}
params := fake.request.TemplateParamSet
if len(params) != 2 || *params[0] != "123456" || *params[1] != "5" {
t.Fatal("registration template must receive code followed by its real expiry in minutes")
}
if err := p.Send(context.Background(), "+8613800000000", "123456", uuid.NewString()); err != nil || len(fake.request.TemplateParamSet) != 1 {
t.Fatal("registration parameters leaked into login template")
}
for _, params := range [][]string{{}, {"expires_minutes"}, {"code", "unknown"}, {"code", "code"}} {
if _, err := NewTencentSMSProviderWithTemplates("id", "key", "1400006666", "安芯伴", templates, map[string][]string{"register": params}); err == nil {
t.Fatal("invalid parameters accepted")
}
}
}
@@ -0,0 +1,67 @@
package onboarding
import (
"context"
"net/http"
"time"
"github.com/jackc/pgx/v5"
)
const trustedLoginLifetime = 30 * 24 * time.Hour
func (s *Service) loginRealm() string {
if s.operatorLogin {
return "operator"
}
return "customer"
}
func (s *Service) trustedLoginCookieName() string {
name := "platform-" + s.loginRealm() + "-trusted-device"
if !s.options.AllowLocalHTTP {
name = "__Host-" + name
}
return name
}
func (s *Service) trustedLoginCookie(r *http.Request) string {
if c, err := r.Cookie(s.trustedLoginCookieName()); err == nil && validB64(c.Value, 32) {
return c.Value
}
return ""
}
// Only a successfully verified, single-use SMS can establish device trust.
// Bind it to the exact credential so an administrative password reset also
// invalidates previously trusted devices without needing a separate revocation.
func (s *Service) rememberLoginDevice(ctx context.Context, tx pgx.Tx, phoneHash string) (string, error) {
var encoded string
if err := tx.QueryRow(ctx, "SELECT COALESCE(password_hash,'') FROM "+s.phoneCredentialTable()+" WHERE phone_digest=$1 FOR SHARE", phoneHash).Scan(&encoded); err != nil {
return "", err
}
token := randomToken(32)
_, err := tx.Exec(ctx, `INSERT INTO trusted_login_device(token_hash,realm,phone_digest,credential_digest,verified_at,expires_at) VALUES($1,$2,$3,$4,$5,$6)`, digest([]byte(token)), s.loginRealm(), phoneHash, digest([]byte(encoded)), s.now(), s.now().Add(trustedLoginLifetime))
return token, err
}
func (s *Service) requireTrustedLoginDevice(ctx context.Context, tx pgx.Tx, phoneHash, encoded, token string) error {
trusted := false
if validB64(token, 32) {
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM trusted_login_device WHERE token_hash=$1 AND realm=$2 AND phone_digest=$3 AND credential_digest=$4 AND expires_at>$5 AND revoked_at IS NULL)`, digest([]byte(token)), s.loginRealm(), phoneHash, digest([]byte(encoded)), s.now()).Scan(&trusted)
if err != nil {
return err
}
}
if !trusted {
return fail(403, "NEW_DEVICE_SMS_REQUIRED", "首次在此设备登录,请使用手机验证码完成验证")
}
return nil
}
func (s *Service) updateTrustedDevicesAfterPassword(ctx context.Context, tx pgx.Tx, phoneHash, oldHash, newHash, currentToken string) error {
// Retain only an existing, unexpired SMS verification on this browser. A
// password change must never turn an unverified browser into a trusted one.
_, err := tx.Exec(ctx, `UPDATE trusted_login_device SET credential_digest=CASE WHEN token_hash=$3 AND credential_digest=$4 AND expires_at>$6 THEN $5 ELSE credential_digest END,revoked_at=CASE WHEN token_hash=$3 AND credential_digest=$4 AND expires_at>$6 THEN NULL ELSE $6 END WHERE realm=$1 AND phone_digest=$2 AND revoked_at IS NULL`, s.loginRealm(), phoneHash, digest([]byte(currentToken)), digest([]byte(oldHash)), digest([]byte(newHash)), s.now())
return err
}
@@ -0,0 +1,168 @@
package onboarding
import (
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
// Existing password/role tests use an explicitly preverified device fixture.
// End-to-end SMS issuance is exercised separately below.
func (f *loginFixture) trustDeviceForTest() {
f.t.Helper()
tx, err := f.s.pool.Begin(f.ctx)
must(f.t, err)
defer tx.Rollback(f.ctx)
token, err := f.s.rememberLoginDevice(f.ctx, tx, domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone))
must(f.t, err)
must(f.t, tx.Commit(f.ctx))
f.trusted = &http.Cookie{Name: f.s.trustedLoginCookieName(), Value: token}
}
func TestNewDeviceRequiresSMSForBothPortals(t *testing.T) {
for _, operator := range []bool{false, true} {
name := "customer"
if operator {
name = "operator"
}
t.Run(name, func(t *testing.T) {
f := newLoginFixture(t)
if operator {
f.s = f.s.operatorView()
f.newLoginSession()
_, err := f.s.ProvisionOperatorPhone(f.ctx, f.operatorID, f.p.Issuer, f.p.Subject, f.phone, "abc123", uuid.NewString())
must(t, err)
} else {
_, err := f.s.ProvisionCustomerPhone(f.ctx, f.web.UserID, f.user.Issuer, f.user.Subject, f.phone, "abc123", uuid.NewString())
must(t, err)
}
login := func(password string) int {
w, _ := f.request("POST", "/api/v1/web/login/password", PasswordLoginInput{Phone: f.phone, Password: password, CaptchaID: f.knownCaptcha(), CaptchaCode: "654321"}, f.cookie, f.csrf, "")
if w.Code == 403 && !strings.Contains(w.Body.String(), "NEW_DEVICE_SMS_REQUIRED") {
t.Fatal(w.Body.String())
}
if w.Code != 200 {
for _, c := range w.Result().Cookies() {
if c.Name == f.s.cookieName(true) {
t.Fatal("account issued before device verification")
}
}
}
return w.Code
}
if got := login("wrong1"); got != 401 {
t.Fatalf("wrong password: %d", got)
}
if got := login("abc123"); got != 403 {
t.Fatalf("new device: %d", got)
}
var consumed bool
must(t, f.s.pool.QueryRow(f.ctx, "SELECT consumed_at IS NOT NULL FROM "+f.s.loginSessionTable()+" WHERE token_hash=$1", digest([]byte(f.cookie.Value))).Scan(&consumed))
if consumed {
t.Fatal("step-up consumed login session")
}
id := f.send()
w, _ := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: "wrong"}, f.cookie, f.csrf, "")
if w.Code == 200 || f.trusted != nil {
t.Fatal("failed SMS trusted device")
}
w, _ = f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 200 || f.trusted == nil {
t.Fatal(w.Body.String())
}
if !f.trusted.HttpOnly || !f.trusted.Secure || f.trusted.MaxAge != 30*86400 || f.trusted.SameSite != http.SameSiteLaxMode {
t.Fatal("unsafe trust cookie")
}
if strings.Contains(w.Body.String(), f.trusted.Value) {
t.Fatal("browser trust token leaked in JSON")
}
trusted := f.trusted
f.newLoginSession()
if got := login("abc123"); got != 200 {
t.Fatalf("verified device: %d", got)
}
f.newLoginSession()
f.trusted = nil
if got := login("abc123"); got != 403 {
t.Fatalf("new browser: %d", got)
}
f.trusted = &http.Cookie{Name: trusted.Name, Value: randomToken(32)}
if got := login("abc123"); got != 403 {
t.Fatalf("forged token: %d", got)
}
f.trusted = trusted
_, err := f.s.pool.Exec(f.ctx, `UPDATE trusted_login_device SET expires_at=$2 WHERE token_hash=$1`, digest([]byte(trusted.Value)), time.Now().Add(-time.Minute))
must(t, err)
if got := login("abc123"); got != 403 {
t.Fatalf("expired trust: %d", got)
}
})
}
}
func TestTrustedDeviceBoundToPhoneRealmAndPassword(t *testing.T) {
f := newLoginFixture(t)
account, csrf := f.smsAccount()
w, _ := f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "abc123"}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
oldDevice := f.trusted
f.trustDeviceForTest()
secondDevice := f.trusted
f.trusted = oldDevice
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "abc456", CurrentPassword: "abc123"}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
phoneHash := domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone)
var encoded string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT password_hash FROM customer_phone_credential WHERE phone_digest=$1`, phoneHash).Scan(&encoded))
tx, err := f.s.pool.Begin(f.ctx)
must(t, err)
defer tx.Rollback(f.ctx)
must(t, f.s.requireTrustedLoginDevice(f.ctx, tx, phoneHash, encoded, oldDevice.Value))
for _, test := range []struct {
phone, hash, token string
operator bool
}{
{phoneHash, encoded, secondDevice.Value, false},
{phoneHash, encoded, oldDevice.Value, true},
{strings.Repeat("0", 64), encoded, oldDevice.Value, false},
{phoneHash, "administrative-reset", oldDevice.Value, false},
} {
view := *f.s
view.operatorLogin = test.operator
if view.requireTrustedLoginDevice(f.ctx, tx, test.phone, test.hash, test.token) == nil {
t.Fatal("unrelated/revoked device accepted")
}
}
}
func TestMiniPasswordCannotBypassDeviceVerification(t *testing.T) {
f := newLoginFixture(t)
_, err := f.s.ProvisionCustomerPhone(f.ctx, f.web.UserID, f.user.Issuer, f.user.Subject, f.phone, "abc123", uuid.NewString())
must(t, err)
status, start := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "customer"})
if status != 200 {
t.Fatal(status)
}
login := start["login_token"].(string)
_, token, _ := miniCredential("MiniLogin "+login, "MiniLogin")
hash := digest([]byte(token))
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/password", "MiniLogin "+login, PasswordLoginInput{Phone: f.phone, Password: "abc123", CaptchaID: f.knownCaptchaFor(hash), CaptchaCode: "654321"})
if status != 403 {
t.Fatal("native endpoint bypassed device verification", status)
}
status, challenge := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/send", "MiniLogin "+login, SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: f.knownCaptchaFor(hash), CaptchaCode: "654321"})
if status != 200 {
t.Fatal(status)
}
status, result := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+login, SMSVerifyInput{ChallengeID: challenge["sms_challenge_id"].(string), Code: f.sender.code})
if status != 200 || result["trusted_device_token"] == nil {
t.Fatal("native SMS trust missing", status)
}
}
+59
View File
@@ -0,0 +1,59 @@
package onboarding
import (
"context"
)
type VoiceRuntimeInput struct {
BootID string `json:"boot_id"`
}
// VoiceRuntime is independent of the short-lived provisioning screen. It
// exposes only this authenticated device's current, bound activation command.
// Registration is owned by the server; the device only acknowledges SDK initialization.
func (s *Service) VoiceRuntime(ctx context.Context, d Device, in VoiceRuntimeInput) (map[string]any, error) {
if !uuidV4(in.BootID) {
return nil, fail(400, "VALIDATION_FAILED", "启动编号不正确")
}
tx, e := s.pool.Begin(ctx)
if e != nil {
return nil, e
}
defer tx.Rollback(ctx)
current, e := readDevice(ctx, tx, d.ID, true)
if e != nil {
return nil, e
}
if !deviceAllowed(current) || current.BootID != in.BootID {
return nil, errState
}
var bound bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND generation=$2 AND unbound_at IS NULL) OR EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND generation=$2 AND released_at IS NULL)`, d.ID, current.Generation).Scan(&bound); e != nil {
return nil, e
}
if !bound {
return nil, fail(409, "DEVICE_UNBOUND", "请先绑定设备")
}
command, e := s.activationCommand(ctx, tx, current)
if e != nil {
return nil, e
}
st, e := s.state(ctx, tx, current)
if e != nil {
return nil, e
}
out := map[string]any{"state": st, "command": command, "call_contract": "CALL-MEMORY-1.1"}
if !s.options.LegacyDeviceRegistration {
fields, err := s.voiceFields(ctx, tx, current)
if err != nil {
return nil, err
}
for k, v := range fields {
out[k] = v
}
}
if e = tx.Commit(ctx); e != nil {
return nil, e
}
return out, nil
}
+37
View File
@@ -0,0 +1,37 @@
package onboarding
import (
"github.com/google/uuid"
"testing"
)
func TestVoiceRuntimeRequiresCurrentBootAndBinding(t *testing.T) {
f := newFixture(t)
code, _ := f.deviceCall("voice-runtime", VoiceRuntimeInput{BootID: uuid.NewString()}, "", true)
if code != 409 {
t.Fatalf("wrong boot: %d", code)
}
_, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: f.bleToken()})
must(t, err)
f.refresh()
boot := uuid.NewString()
_, err = f.s.pool.Exec(f.ctx, `UPDATE device SET onboarding_boot_id=$2::uuid WHERE id=$1::uuid`, f.d.ID, boot)
must(t, err)
f.refresh()
code, out := f.deviceCall("voice-runtime", VoiceRuntimeInput{BootID: boot}, "", true)
data := dataOK(t, code, out, "state command call_contract")
if data["call_contract"] != "CALL-MEMORY-1.1" || data["command"] == nil {
t.Fatal("missing signed activation command")
}
// Reading the command must never contact the provider or mark the device READY.
if f.registrar.calls.Load() != 0 {
t.Fatal("runtime read consumed provider activation")
}
if data["state"].(map[string]any)["activation_state"] == "READY" {
t.Fatal("runtime read bypassed first test")
}
code, _ = f.deviceCall("voice-runtime", VoiceRuntimeInput{BootID: boot}, "", false)
if code != 400 {
t.Fatalf("HTTP accepted: %d", code)
}
}
+16 -7
View File
@@ -8,6 +8,7 @@ import (
"time"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/saas"
"github.com/jackc/pgx/v5"
)
@@ -17,10 +18,11 @@ type WebSession struct {
Expires time.Time
}
type BrowserCredentials struct {
Token string `json:"-"`
CSRF string `json:"csrf_token"`
AccountToken string `json:"-"`
Data map[string]any `json:"data"`
TrustedDeviceToken string `json:"-"`
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) {
@@ -389,12 +391,16 @@ func (s *Service) Profiles(ctx context.Context, w WebSession) (map[string]any, e
}
type ProfileInput struct {
HouseholdID string `json:"household_id,omitempty"`
HouseholdName string `json:"household_name,omitempty"`
Name string `json:"preferred_name"`
InitialConsents saas.InitialConsentSelection `json:"initial_consents,omitempty"`
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 err := saas.ValidateInitialConsents(in.InitialConsents); err != nil {
return nil, fail(400, "VALIDATION_FAILED", "请逐项选择授权并确认用途")
}
if w.UserID == "" || !nonempty(in.Name, 120) || (in.HouseholdID == "" && !nonempty(in.HouseholdName, 200)) || (in.HouseholdID != "" && !uuidValid(in.HouseholdID)) {
return nil, fail(400, "VALIDATION_FAILED", "家庭和使用者信息不正确")
}
@@ -424,6 +430,9 @@ func (s *Service) CreateProfile(ctx context.Context, w WebSession, requestID str
if err != nil {
return nil, err
}
if err = saas.InitializeSubjectMemory(ctx, tx, d.TenantID, id, w.UserID, requestID, in.InitialConsents); 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
}
+160
View File
@@ -0,0 +1,160 @@
package onboarding
import (
"context"
"errors"
"time"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
type WeChatLoginInput struct {
LoginCode string `json:"login_code"`
PhoneCode string `json:"phone_code,omitempty"`
}
type WeChatPasswordInput struct {
PasswordLoginInput
LoginCode string `json:"login_code"`
}
func (s *Service) weChatPasswordLogin(ctx context.Context, hash string, in WeChatPasswordInput) (map[string]any, error) {
if s.operatorLogin {
return nil, fail(403, "CUSTOMER_REQUIRED", "微信登录仅用于家庭账号")
}
if s.options.WeChat == nil || s.options.WeChatAppID == "" {
return nil, fail(503, "WECHAT_NOT_CONFIGURED", "微信登录尚未开通,请使用手机号登录")
}
if in.LoginCode == "" || len(in.LoginCode) > 512 {
return nil, fail(400, "INVALID_WECHAT_CODE", "请重新发起微信登录")
}
if err := s.limit(ctx, "wechat-login:"+hash, 12, time.Minute); err != nil {
return nil, err
}
openID, err := s.options.WeChat.OpenID(ctx, in.LoginCode)
if err != nil {
return nil, err
}
out, err := s.passwordLoginLinked(ctx, hash, in.PasswordLoginInput, openID)
if err != nil {
return nil, err
}
return map[string]any{"access_token": "customer." + out.AccountToken, "portal": "customer", "expires_in": 86400}, nil
}
func (s *Service) linkWeChatPhone(ctx context.Context, tx pgx.Tx, openID, phoneHash string) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,65))`, s.options.WeChatAppID+":"+openID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,65))`, s.options.WeChatAppID+":phone:"+phoneHash); err != nil {
return err
}
var conflict bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM customer_wechat_identity WHERE app_id=$1 AND ((open_id=$2 AND phone_digest<>$3) OR (open_id<>$2 AND phone_digest=$3)))`, s.options.WeChatAppID, openID, phoneHash).Scan(&conflict); err != nil {
return err
}
if conflict {
return fail(409, "WECHAT_PHONE_CONFLICT", "微信或手机号已关联其他账号,请使用原账号登录")
}
_, err := tx.Exec(ctx, `INSERT INTO customer_wechat_identity(app_id,open_id,phone_digest) VALUES($1,$2,$3) ON CONFLICT(app_id,open_id) DO NOTHING`, s.options.WeChatAppID, openID, phoneHash)
return err
}
func (s *Service) weChatLogin(ctx context.Context, sessionHash string, in WeChatLoginInput) (map[string]any, error) {
if s.operatorLogin {
return nil, fail(403, "CUSTOMER_REQUIRED", "微信登录仅用于家庭账号")
}
if s.options.WeChat == nil || s.options.WeChatAppID == "" {
return nil, fail(503, "WECHAT_NOT_CONFIGURED", "微信登录尚未开通,请使用手机号登录")
}
if in.LoginCode == "" || len(in.LoginCode) > 512 || len(in.PhoneCode) > 512 {
return nil, fail(400, "INVALID_WECHAT_CODE", "请重新发起微信登录")
}
if err := s.limit(ctx, "wechat-login:"+sessionHash, 12, time.Minute); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, 18*time.Second)
defer cancel()
openID, err := s.options.WeChat.OpenID(ctx, in.LoginCode)
if err != nil {
return nil, err
}
// Reuse the existing identity on later logins; a client never supplies an
// openid or an unverified phone number to choose the account it receives.
verifiedPhone := ""
if in.PhoneCode != "" {
verifiedPhone, err = s.options.WeChat.Phone(ctx, in.PhoneCode)
if err != nil {
return nil, err
}
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err = s.checkLoginSession(ctx, tx, sessionHash); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,65))`, s.options.WeChatAppID+":"+openID); err != nil {
return nil, err
}
var phoneHash string
err = tx.QueryRow(ctx, `SELECT phone_digest FROM customer_wechat_identity WHERE app_id=$1 AND open_id=$2`, s.options.WeChatAppID, openID).Scan(&phoneHash)
fresh := errors.Is(err, pgx.ErrNoRows)
if err != nil && !fresh {
return nil, err
}
if fresh && verifiedPhone == "" {
return map[string]any{"phone_required": true}, nil
}
if verifiedPhone != "" {
if !phonePattern.MatchString(verifiedPhone) {
return nil, fail(400, "WECHAT_PHONE_INVALID", "手机号授权失败")
}
verifiedHash := domainMAC(s.crypt.hashKey, "PHONE1\n"+verifiedPhone)
if !fresh && phoneHash != verifiedHash {
return nil, fail(409, "WECHAT_PHONE_CONFLICT", "微信已关联其他手机号,请使用原账号登录")
}
phoneHash = verifiedHash
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,65))`, s.options.WeChatAppID+":phone:"+phoneHash); err != nil {
return nil, err
}
if fresh {
var other bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM customer_wechat_identity WHERE app_id=$1 AND phone_digest=$2)`, s.options.WeChatAppID, phoneHash).Scan(&other); err != nil {
return nil, err
}
if other {
return nil, fail(409, "WECHAT_PHONE_CONFLICT", "该手机号已关联其他微信,请使用手机号密码登录")
}
p := identity.Principal{Issuer: "urn:platform:onboarding:sms", Subject: phoneHash}
masked := verifiedPhone[:len(verifiedPhone)-8] + "****" + verifiedPhone[len(verifiedPhone)-4:]
if _, err = tx.Exec(ctx, `INSERT INTO customer_phone_credential(phone_digest,phone_masked,issuer,subject) VALUES($1,$2,$3,$4) ON CONFLICT(phone_digest) DO NOTHING`, phoneHash, masked, p.Issuer, p.Subject); err != nil {
return nil, err
}
}
var p identity.Principal
err = tx.QueryRow(ctx, `SELECT issuer,subject,phone_masked FROM customer_phone_credential WHERE phone_digest=$1 FOR SHARE`, phoneHash).Scan(&p.Issuer, &p.Subject, &p.DisplayName)
if err != nil {
return nil, err
}
out, err := s.attachCustomerLogin(ctx, tx, sessionHash, p, fresh)
if err != nil {
return nil, err
}
if fresh {
if _, err = tx.Exec(ctx, `INSERT INTO customer_wechat_identity(app_id,open_id,phone_digest) VALUES($1,$2,$3)`, s.options.WeChatAppID, openID, phoneHash); err != nil {
return nil, err
}
}
// Merely logging in with an already-linked OpenID is not a fresh SMS/phone
// verification and must not grant password-reset privileges.
if _, err = tx.Exec(ctx, `UPDATE onboarding_account_session SET phone_digest=$2 WHERE token_hash=$1`, digest([]byte(out.AccountToken)), phoneHash); err != nil {
return nil, err
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
return map[string]any{"access_token": "customer." + out.AccountToken, "portal": "customer", "expires_in": 86400}, nil
}
+131
View File
@@ -0,0 +1,131 @@
package onboarding
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"sync"
"time"
)
type WeChatProvider interface {
OpenID(context.Context, string) (string, error)
Phone(context.Context, string) (string, error)
}
// AppSecret and provider tokens stay server-side. Provider errors must not
// include the request URL: WeChat places credentials in query parameters.
type weChatClient struct {
appID, secret, base string
http *http.Client
mu sync.Mutex
token string
expires time.Time
}
func newWeChatClient(appID, secret string) *weChatClient {
return &weChatClient{appID: appID, secret: secret, base: "https://api.weixin.qq.com", http: &http.Client{Timeout: 6 * time.Second}}
}
func (c *weChatClient) call(ctx context.Context, path string, query url.Values, body any, out any) error {
method := http.MethodGet
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(raw)
method = http.MethodPost
}
u := c.base + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u, reader)
if err != nil {
return fail(503, "WECHAT_UNAVAILABLE", "微信服务暂不可用,请稍后重试")
}
req.Header.Set("Content-Type", "application/json")
res, err := c.http.Do(req)
if err != nil {
return fail(503, "WECHAT_UNAVAILABLE", "微信服务暂不可用,请稍后重试")
}
defer res.Body.Close()
if res.StatusCode != 200 || json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(out) != nil {
return fail(503, "WECHAT_UNAVAILABLE", "微信服务响应异常,请稍后重试")
}
return nil
}
func (c *weChatClient) OpenID(ctx context.Context, code string) (string, error) {
var out struct {
OpenID string `json:"openid"`
ErrCode int `json:"errcode"`
}
err := c.call(ctx, "/sns/jscode2session", url.Values{"appid": {c.appID}, "secret": {c.secret}, "js_code": {code}, "grant_type": {"authorization_code"}}, nil, &out)
if err != nil {
return "", err
}
if out.ErrCode != 0 || out.OpenID == "" || len(out.OpenID) > 128 {
return "", fail(401, "WECHAT_LOGIN_FAILED", "微信登录已失效,请重新点击登录")
}
return out.OpenID, nil
}
func (c *weChatClient) accessToken(ctx context.Context, invalid string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && c.token != invalid && time.Now().Before(c.expires) {
return c.token, nil
}
var out struct {
Token string `json:"access_token"`
Expires int `json:"expires_in"`
ErrCode int `json:"errcode"`
}
err := c.call(ctx, "/cgi-bin/stable_token", nil, map[string]any{"grant_type": "client_credential", "appid": c.appID, "secret": c.secret, "force_refresh": invalid != ""}, &out)
if err != nil {
return "", err
}
if out.ErrCode != 0 || out.Token == "" || out.Expires <= 60 {
return "", fail(503, "WECHAT_CONFIG_ERROR", "微信登录尚未配置完成,请使用手机号登录")
}
c.token = out.Token
c.expires = time.Now().Add(time.Duration(out.Expires-60) * time.Second)
return c.token, nil
}
func (c *weChatClient) Phone(ctx context.Context, code string) (string, error) {
invalid := ""
for i := 0; i < 2; i++ {
token, err := c.accessToken(ctx, invalid)
if err != nil {
return "", err
}
var out struct {
ErrCode int `json:"errcode"`
Info struct {
Number string `json:"purePhoneNumber"`
Country string `json:"countryCode"`
Watermark struct {
AppID string `json:"appid"`
} `json:"watermark"`
} `json:"phone_info"`
}
if err = c.call(ctx, "/wxa/business/getuserphonenumber", url.Values{"access_token": {token}}, map[string]string{"code": code}, &out); err != nil {
return "", err
}
if out.ErrCode == 40001 || out.ErrCode == 40014 || out.ErrCode == 42001 {
invalid = token
continue
}
if out.ErrCode == 48001 || out.ErrCode == 48004 || out.ErrCode == 200003 {
return "", fail(503, "WECHAT_PHONE_UNAVAILABLE", "小程序尚未开通手机号授权,请使用手机号登录")
}
if out.ErrCode != 0 || out.Info.Watermark.AppID != c.appID || !phonePattern.MatchString("+"+out.Info.Country+out.Info.Number) {
return "", fail(400, "WECHAT_PHONE_INVALID", "手机号授权失败,请重新授权或使用密码登录")
}
return "+" + out.Info.Country + out.Info.Number, nil
}
return "", fail(503, "WECHAT_UNAVAILABLE", "微信服务暂不可用,请稍后重试")
}
+68
View File
@@ -0,0 +1,68 @@
package onboarding
import (
"strings"
"testing"
"github.com/google/uuid"
)
// A verified new phone must enter the family-creation flow without a prior
// customer account, household membership, or device binding.
func TestWeChatNewPhoneCreatesCustomerWithoutFamilyOrDevice(t *testing.T) {
f := newLoginFixture(t)
provider := &fakeWeChat{openID: uuid.NewString(), phone: f.phone}
f.s.options.WeChat = provider
f.s.options.WeChatAppID = "wx4553119e05f11660"
phoneHash := domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone)
start := func() string {
status, data := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "customer"})
if status != 200 {
t.Fatalf("session status %d", status)
}
return "MiniLogin " + data["login_token"].(string)
}
auth := start()
status, data := miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "before-phone"})
if status != 200 || data["phone_required"] != true {
t.Fatal("new identity must request phone authorization")
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM customer_phone_credential WHERE phone_digest=$1`, phoneHash).Scan(&count))
if count != 0 {
t.Fatal("created phone before authorization")
}
status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "authorized", PhoneCode: "verified-phone-code"})
if status != 200 || data["access_token"] == nil {
t.Fatalf("verified new phone was not registered: %d", status)
}
token := strings.TrimPrefix(data["access_token"].(string), "customer.")
principal, err := f.s.AccountPrincipal(f.ctx, token, "", false)
must(t, err)
var uid, kind, tenant string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT id::text,account_kind,tenant_id::text FROM user_account WHERE oidc_issuer=$1 AND oidc_subject=$2 AND status='active'`, principal.Issuer, principal.Subject).Scan(&uid, &kind, &tenant))
if kind != "customer" || tenant != f.tenant {
t.Fatal("wrong signup account kind or tenant")
}
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM household_member WHERE user_id=$1`, uid).Scan(&count))
if count != 0 {
t.Fatal("signup must not create or join a household")
}
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM device_binding WHERE bound_by=$1`, uid).Scan(&count))
if count != 0 {
t.Fatal("signup must not require device binding")
}
status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", start(), WeChatLoginInput{LoginCode: "repeat-login"})
if status != 200 || data["access_token"] == nil {
t.Fatal("repeat login failed")
}
again, err := f.s.AccountPrincipal(f.ctx, strings.TrimPrefix(data["access_token"].(string), "customer."), "", false)
must(t, err)
if again.Subject != principal.Subject || again.Issuer != principal.Issuer {
t.Fatal("repeat login changed account")
}
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM user_account WHERE oidc_issuer=$1 AND oidc_subject=$2`, principal.Issuer, principal.Subject).Scan(&count))
if count != 1 {
t.Fatal("repeat login duplicated account")
}
}
+221
View File
@@ -0,0 +1,221 @@
package onboarding
import (
"context"
"encoding/json"
"github.com/google/uuid"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type fakeWeChat struct{ openID, phone string }
func (f *fakeWeChat) OpenID(context.Context, string) (string, error) { return f.openID, nil }
func (f *fakeWeChat) Phone(context.Context, string) (string, error) { return f.phone, nil }
func TestWeChatProviderExchangeCacheRefreshAndErrors(t *testing.T) {
tokens, phones := 0, 0
mode := "ok"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/sns/jscode2session":
if r.URL.Query().Get("js_code") != "login" || r.URL.Query().Get("appid") != "wx-test" {
t.Error("bad login request")
}
if mode == "badlogin" {
json.NewEncoder(w).Encode(map[string]any{"errcode": 40029, "errmsg": "secret-value"})
return
}
json.NewEncoder(w).Encode(map[string]string{"openid": "openid-test"})
case "/cgi-bin/stable_token":
tokens++
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
if r.Method != "POST" || body["secret"] != "secret-value" {
t.Error("bad token request")
}
json.NewEncoder(w).Encode(map[string]any{"access_token": "token-" + strings.Repeat("x", tokens), "expires_in": 7200})
case "/wxa/business/getuserphonenumber":
phones++
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["code"] != "phone-code" || r.URL.Query().Get("access_token") == "" {
t.Error("bad phone request")
}
if mode == "refresh" && phones == 3 {
json.NewEncoder(w).Encode(map[string]int{"errcode": 40014})
return
}
if mode == "denied" {
json.NewEncoder(w).Encode(map[string]int{"errcode": 48001})
return
}
appID := "wx-test"
if mode == "watermark" {
appID = "other-app"
}
json.NewEncoder(w).Encode(map[string]any{"phone_info": map[string]any{"purePhoneNumber": "13800000000", "countryCode": "86", "watermark": map[string]string{"appid": appID}}})
default:
t.Error("unexpected endpoint")
w.WriteHeader(404)
}
}))
defer server.Close()
c := newWeChatClient("wx-test", "secret-value")
c.base = server.URL
id, err := c.OpenID(context.Background(), "login")
must(t, err)
if id != "openid-test" {
t.Fatal(id)
}
for i := 0; i < 2; i++ {
phone, err := c.Phone(context.Background(), "phone-code")
must(t, err)
if phone != "+8613800000000" {
t.Fatal(phone)
}
}
if tokens != 1 {
t.Fatal("token not cached", tokens)
}
mode = "refresh"
_, err = c.Phone(context.Background(), "phone-code")
must(t, err)
if tokens != 2 || phones != 4 {
t.Fatal("refresh retry", tokens, phones)
}
for _, m := range []string{"denied", "watermark", "badlogin"} {
mode = m
if m == "badlogin" {
_, err = c.OpenID(context.Background(), "login")
} else {
_, err = c.Phone(context.Background(), "phone-code")
}
if err == nil || strings.Contains(err.Error(), "secret-value") {
t.Fatal("invalid provider response accepted or secret leaked", m)
}
}
server.Close()
_, err = c.OpenID(context.Background(), "login")
if err == nil || strings.Contains(err.Error(), "secret-value") {
t.Fatal("network error leaked credentials")
}
}
func TestWeChatLoginPreservesAccountAndRejectsConflicts(t *testing.T) {
f := newLoginFixture(t)
account, csrf := f.smsAccount()
original, err := f.s.AccountPrincipal(f.ctx, account.Value, csrf, true)
must(t, err)
provider := &fakeWeChat{openID: uuid.NewString(), phone: f.phone}
f.s.options.WeChat = provider
f.s.options.WeChatAppID = "wx4553119e05f11660"
login := func(portal string) string {
s, d := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": portal})
if s != 200 {
t.Fatal(s)
}
return "MiniLogin " + d["login_token"].(string)
}
auth := login("customer")
status, data := miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "first"})
if status != 200 || data["phone_required"] != true || data["access_token"] != nil {
t.Fatal("first login bypassed phone", status)
}
status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "second", PhoneCode: "phone"})
if status != 200 {
t.Fatal(status)
}
check := func(data map[string]any) {
token := strings.TrimPrefix(data["access_token"].(string), "customer.")
p, err := f.s.AccountPrincipal(f.ctx, token, "", false)
must(t, err)
if p.Issuer != original.Issuer || p.Subject != original.Subject {
t.Fatal("account changed")
}
var verified bool
must(t, f.s.pool.QueryRow(f.ctx, `SELECT phone_verified_at IS NOT NULL FROM onboarding_account_session WHERE token_hash=$1`, digest([]byte(token))).Scan(&verified))
if verified {
t.Fatal("login granted password reset")
}
}
check(data)
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "replay"})
if status != 401 {
t.Fatal("session replay", status)
}
status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", login("customer"), WeChatLoginInput{LoginCode: "next"})
if status != 200 {
t.Fatal(status)
}
check(data)
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", login("operator"), WeChatLoginInput{LoginCode: "operator"})
if status != 403 {
t.Fatal("operator accepted", status)
}
provider.openID = uuid.NewString()
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", login("customer"), WeChatLoginInput{LoginCode: "other", PhoneCode: "phone"})
if status != 409 {
t.Fatal("phone conflict accepted", status)
}
provider.openID = uuid.NewString()
f.s.options.SelfSignupTenantIDs = nil
provider.phone = "+8613900000001"
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", login("customer"), WeChatLoginInput{LoginCode: "new", PhoneCode: "phone"})
if status != 403 {
t.Fatal("unconfigured signup accepted", status)
}
}
func TestWeChatPasswordLinkAtomicAndLaterQuickLogin(t *testing.T) {
f := newLoginFixture(t)
account, csrf := f.smsAccount()
w, _ := f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Code)
}
provider := &fakeWeChat{openID: uuid.NewString(), phone: f.phone}
f.s.options.WeChat = provider
f.s.options.WeChatAppID = "wx4553119e05f11660"
start := func() (string, string) {
status, data := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "customer"})
if status != 200 {
t.Fatal(status)
}
auth := "MiniLogin " + data["login_token"].(string)
_, token, _ := miniCredential(auth, "MiniLogin")
return auth, digest([]byte(token))
}
auth, hash := start()
in := WeChatPasswordInput{PasswordLoginInput: PasswordLoginInput{Phone: f.phone, Password: "Wrong123!", CaptchaID: f.knownCaptchaFor(hash), CaptchaCode: "654321", TrustedDeviceToken: f.trusted.Value}, LoginCode: "link"}
status, _ := miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat-link", auth, in)
if status != 401 {
t.Fatal(status)
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM customer_wechat_identity WHERE open_id=$1`, provider.openID).Scan(&count))
if count != 0 {
t.Fatal("wrong password linked identity")
}
in.Password = "LocalOnly123!"
in.CaptchaID = f.knownCaptchaFor(hash)
status, data := miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat-link", auth, in)
if status != 200 || data["access_token"] == nil {
t.Fatal("password link failed", status)
}
auth, _ = start()
status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "later"})
if status != 200 || data["access_token"] == nil {
t.Fatal("quick login failed", status)
}
_, err := f.s.pool.Exec(f.ctx, `UPDATE user_account SET status='disabled' WHERE oidc_issuer='urn:platform:onboarding:sms' AND oidc_subject=$1`, domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone))
must(t, err)
auth, _ = start()
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "disabled"})
if status != 403 {
t.Fatal("disabled user accepted", status)
}
}