feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
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:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(¤t, &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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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", "接口不存在")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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, ¤t)
|
||||
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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(®istered); 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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", "微信服务暂不可用,请稍后重试")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user