169 lines
6.4 KiB
Go
169 lines
6.4 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|