Files
story/internal/onboarding/customer_login_test.go
T

385 lines
14 KiB
Go
Raw Normal View History

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