198 lines
8.1 KiB
Go
198 lines
8.1 KiB
Go
package onboarding
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
func miniRequest(t *testing.T, s *Service, method, path, auth string, in any) (int, map[string]any) {
|
||
|
|
t.Helper()
|
||
|
|
raw, _ := json.Marshal(in)
|
||
|
|
r := httptest.NewRequest(method, "https://platform.example"+path, bytes.NewReader(raw))
|
||
|
|
r.RemoteAddr = "127.0.0.1:3456"
|
||
|
|
r.Header.Set("Content-Type", "application/json")
|
||
|
|
r.Header.Set("X-Request-ID", uuid.NewString())
|
||
|
|
r.Header.Set("Authorization", auth)
|
||
|
|
w := httptest.NewRecorder()
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
s.RegisterRoutes(mux)
|
||
|
|
mux.ServeHTTP(w, r)
|
||
|
|
if len(w.Result().Cookies()) != 0 {
|
||
|
|
t.Fatal("native API issued ambient cookie")
|
||
|
|
}
|
||
|
|
var out map[string]any
|
||
|
|
must(t, json.Unmarshal(w.Body.Bytes(), &out))
|
||
|
|
data, _ := out["data"].(map[string]any)
|
||
|
|
return w.Code, data
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMiniCredentialsStrictRealm(t *testing.T) {
|
||
|
|
good := "customer." + randomToken(32)
|
||
|
|
if _, _, ok := miniCredential("Mini "+good, "Mini"); !ok {
|
||
|
|
t.Fatal("valid token rejected")
|
||
|
|
}
|
||
|
|
for _, value := range []string{"Bearer " + good, "Mini admin." + randomToken(32), "Mini customer.short", "Mini " + good + "\n", "MiniLogin " + good} {
|
||
|
|
if _, _, ok := miniCredential(value, "Mini"); ok {
|
||
|
|
t.Fatal("malformed token accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMiniSMSLoginSharesCustomerIdentityAndNoCookies(t *testing.T) {
|
||
|
|
f := newLoginFixture(t)
|
||
|
|
_, err := f.s.ProvisionCustomerPhone(f.ctx, f.web.UserID, f.user.Issuer, f.user.Subject, f.phone, "CustomerOnly123!", uuid.NewString())
|
||
|
|
must(t, err)
|
||
|
|
status, session := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "customer"})
|
||
|
|
if status != 200 || session["sms_enabled"] != true {
|
||
|
|
t.Fatal("mini SMS session unavailable", status)
|
||
|
|
}
|
||
|
|
loginToken := session["login_token"].(string)
|
||
|
|
_, token, _ := miniCredential("MiniLogin "+loginToken, "MiniLogin")
|
||
|
|
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/captcha", "MiniLogin "+loginToken, struct{}{})
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal("mini captcha unavailable", status)
|
||
|
|
}
|
||
|
|
in := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: f.knownCaptchaFor(digest([]byte(token))), CaptchaCode: "654321"}
|
||
|
|
status, challenge := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/send", "MiniLogin "+loginToken, in)
|
||
|
|
if status != 200 || f.sender.calls != 1 || challenge["sms_challenge_id"] == nil {
|
||
|
|
t.Fatal("mini SMS was not sent", status, challenge)
|
||
|
|
}
|
||
|
|
status, result := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+loginToken, SMSVerifyInput{ChallengeID: challenge["sms_challenge_id"].(string), Code: f.sender.code})
|
||
|
|
if status != 200 || result["portal"] != "customer" {
|
||
|
|
t.Fatal("mini SMS login failed", status, result)
|
||
|
|
}
|
||
|
|
auth := "Mini " + result["access_token"].(string)
|
||
|
|
status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", auth, nil)
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal("mini account did not inherit customer identity", status)
|
||
|
|
}
|
||
|
|
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+loginToken, SMSVerifyInput{ChallengeID: challenge["sms_challenge_id"].(string), Code: f.sender.code})
|
||
|
|
if status != 401 {
|
||
|
|
t.Fatal("mini SMS challenge was reused", status)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMiniSMSOperatorCannotSelfRegister(t *testing.T) {
|
||
|
|
f := newOperatorLoginFixture(t)
|
||
|
|
status, session := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "operator"})
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal(status)
|
||
|
|
}
|
||
|
|
loginToken := session["login_token"].(string)
|
||
|
|
_, token, _ := miniCredential("MiniLogin "+loginToken, "MiniLogin")
|
||
|
|
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/captcha", "MiniLogin "+loginToken, struct{}{})
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal(status)
|
||
|
|
}
|
||
|
|
in := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: f.knownCaptchaFor(digest([]byte(token))), CaptchaCode: "654321"}
|
||
|
|
status, challenge := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/send", "MiniLogin "+loginToken, in)
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal(status)
|
||
|
|
}
|
||
|
|
verify := SMSVerifyInput{ChallengeID: challenge["sms_challenge_id"].(string), Code: f.sender.code}
|
||
|
|
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+loginToken, verify)
|
||
|
|
if status != 403 {
|
||
|
|
t.Fatal("unprovisioned operator registered", status)
|
||
|
|
}
|
||
|
|
_, err := f.s.ProvisionOperatorPhone(f.ctx, f.operatorID, f.p.Issuer, f.p.Subject, f.phone, "OperatorOnly123!", uuid.NewString())
|
||
|
|
must(t, err)
|
||
|
|
status, result := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+loginToken, verify)
|
||
|
|
if status != 200 || result["portal"] != "operator" {
|
||
|
|
t.Fatal("provisioned operator could not log in", status, result)
|
||
|
|
}
|
||
|
|
auth := "Mini " + result["access_token"].(string)
|
||
|
|
status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", auth, nil)
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal("operator session rejected", status)
|
||
|
|
}
|
||
|
|
status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", strings.Replace(auth, "operator.", "customer.", 1), nil)
|
||
|
|
if status != 401 {
|
||
|
|
t.Fatal("operator token crossed customer realm", status)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMiniPasswordRealmRevocationAndNoCookies(t *testing.T) {
|
||
|
|
f := newLoginFixture(t)
|
||
|
|
account, csrf := f.smsAccount()
|
||
|
|
w, _ := f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, csrf, "")
|
||
|
|
if w.Code != 200 {
|
||
|
|
t.Fatal(w.Code)
|
||
|
|
}
|
||
|
|
status, login := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "customer"})
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal(status)
|
||
|
|
}
|
||
|
|
loginToken := login["login_token"].(string)
|
||
|
|
_, token, _ := miniCredential("MiniLogin "+loginToken, "MiniLogin")
|
||
|
|
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/captcha", "MiniLogin "+loginToken, struct{}{})
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal(status)
|
||
|
|
}
|
||
|
|
id := f.knownCaptchaFor(digest([]byte(token)))
|
||
|
|
status, result := miniRequest(t, f.s, "POST", "/api/v1/mini/login/password", "MiniLogin "+loginToken, PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!", CaptchaID: id, CaptchaCode: "654321", TrustedDeviceToken: f.trusted.Value})
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal(status)
|
||
|
|
}
|
||
|
|
auth := "Mini " + result["access_token"].(string)
|
||
|
|
status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", auth, nil)
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal(status)
|
||
|
|
}
|
||
|
|
status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", strings.Replace(auth, "customer.", "operator.", 1), nil)
|
||
|
|
if status != 401 {
|
||
|
|
t.Fatal("realm substitution", status)
|
||
|
|
}
|
||
|
|
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/captcha", "MiniLogin "+loginToken, struct{}{})
|
||
|
|
if status != 401 {
|
||
|
|
t.Fatal("consumed login reused", status)
|
||
|
|
}
|
||
|
|
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/logout", auth, struct{}{})
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal(status)
|
||
|
|
}
|
||
|
|
status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", auth, nil)
|
||
|
|
if status != 401 {
|
||
|
|
t.Fatal("revoked session accepted", status)
|
||
|
|
}
|
||
|
|
// Existing web writes still require their own Origin and CSRF.
|
||
|
|
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ChangedOnly123!"}, account, "wrong", "")
|
||
|
|
if w.Code != 403 {
|
||
|
|
t.Fatal("web CSRF weakened")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestBLEAutomaticSession(t *testing.T) {
|
||
|
|
f := newFixture(t)
|
||
|
|
in := BLESessionInput{DeviceID: f.d.FactoryID, Model: f.d.Model, KeyVersion: f.d.KeyVersion, ClientNonce: randomToken(16), ServerNonce: randomToken(16)}
|
||
|
|
in.Proof = domainMAC(f.d.Key, "BLE-PROOF1\n"+bleTranscript(in))
|
||
|
|
result, err := f.s.BLESession(f.ctx, f.user, in)
|
||
|
|
must(t, err)
|
||
|
|
if result["session_password"] != domainMAC(f.d.Key, "BLE-PASS1\n"+bleTranscript(in)) || result["session_password"] == in.Proof {
|
||
|
|
t.Fatal("wrong or non-domain-separated password")
|
||
|
|
}
|
||
|
|
_, err = f.s.BLESession(f.ctx, f.user, in)
|
||
|
|
must(t, err)
|
||
|
|
bad := in
|
||
|
|
bad.ServerNonce = randomToken(16)
|
||
|
|
_, err = f.s.BLESession(f.ctx, f.user, bad)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("tampered transcript accepted")
|
||
|
|
}
|
||
|
|
_, err = f.s.BLESession(f.ctx, f.p, in)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("operator obtained family pairing password")
|
||
|
|
}
|
||
|
|
must(t, f.s.redis.Set(f.ctx, "onboarding:ble-link:"+digest([]byte(in.Proof)), "different-account", time.Minute).Err())
|
||
|
|
_, err = f.s.BLESession(f.ctx, f.user, in)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("cross-account replay accepted")
|
||
|
|
}
|
||
|
|
}
|