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