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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user