Files
story/internal/onboarding/trusted_login_device_test.go
T

169 lines
6.4 KiB
Go
Raw Normal View History

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