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