385 lines
14 KiB
Go
385 lines
14 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|