159 lines
6.0 KiB
Go
159 lines
6.0 KiB
Go
package onboarding
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
func newOperatorLoginFixture(t *testing.T) *loginFixture {
|
||
|
|
f := newLoginFixture(t)
|
||
|
|
f.s = f.s.operatorView()
|
||
|
|
f.newLoginSession()
|
||
|
|
f.mux.Handle("POST /operator-write", f.s.CustomerAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })))
|
||
|
|
return f
|
||
|
|
}
|
||
|
|
|
||
|
|
func operatorPasswordSession(t *testing.T, f *loginFixture, password string) (*http.Cookie, string) {
|
||
|
|
t.Helper()
|
||
|
|
if f.trusted == nil || f.trusted.Name != f.s.trustedLoginCookieName() {
|
||
|
|
f.trustDeviceForTest()
|
||
|
|
}
|
||
|
|
in := PasswordLoginInput{Phone: f.phone, Password: password, CaptchaID: f.knownCaptcha(), CaptchaCode: "654321"}
|
||
|
|
w, data := f.request("POST", "/api/v1/web/login/password", in, f.cookie, f.csrf, "")
|
||
|
|
if w.Code != 200 {
|
||
|
|
t.Fatal(w.Body.String())
|
||
|
|
}
|
||
|
|
for _, c := range w.Result().Cookies() {
|
||
|
|
if c.Name == f.s.cookieName(true) {
|
||
|
|
return c, data["csrf_token"].(string)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
t.Fatal("missing operator cookie")
|
||
|
|
return nil, ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOperatorPhoneLoginKeepsRolesAndIsolatesCustomerSession(t *testing.T) {
|
||
|
|
f := newOperatorLoginFixture(t)
|
||
|
|
_, err := f.s.ProvisionOperatorPhone(f.ctx, f.operatorID, f.p.Issuer, f.p.Subject, f.phone, "OperatorOnly123!", uuid.NewString())
|
||
|
|
must(t, err)
|
||
|
|
account, csrf := operatorPasswordSession(t, f, "OperatorOnly123!")
|
||
|
|
if account.Name != "__Host-platform-operator" || !account.Secure || !account.HttpOnly || account.SameSite != http.SameSiteLaxMode {
|
||
|
|
t.Fatal("operator cookie protections")
|
||
|
|
}
|
||
|
|
p, err := f.s.AccountPrincipal(f.ctx, account.Value, csrf, true)
|
||
|
|
must(t, err)
|
||
|
|
if p.Subject != f.p.Subject {
|
||
|
|
t.Fatal("wrong operator identity")
|
||
|
|
}
|
||
|
|
w, _ := f.request("POST", "/operator-write", struct{}{}, account, "wrong", "")
|
||
|
|
if w.Code != 403 {
|
||
|
|
t.Fatal("CSRF bypass")
|
||
|
|
}
|
||
|
|
w, _ = f.request("POST", "/operator-write", struct{}{}, account, csrf, "")
|
||
|
|
if w.Code != 204 {
|
||
|
|
t.Fatal(w.Body.String())
|
||
|
|
}
|
||
|
|
r := httptest.NewRequest("POST", "https://platform.example/operator-write", strings.NewReader("{}"))
|
||
|
|
r.Header.Set("Origin", "https://other.example")
|
||
|
|
r.Header.Set("X-CSRF-Token", csrf)
|
||
|
|
r.AddCookie(account)
|
||
|
|
w2 := httptest.NewRecorder()
|
||
|
|
f.mux.ServeHTTP(w2, r)
|
||
|
|
if w2.Code != 403 {
|
||
|
|
t.Fatal("cross-origin operator write")
|
||
|
|
}
|
||
|
|
_, state := f.request("GET", "/api/v1/web/account/password", nil, account, "", "")
|
||
|
|
if state["password_configured"] != true || state["sms_verified_recently"] != false {
|
||
|
|
t.Fatal("password login granted SMS proof")
|
||
|
|
}
|
||
|
|
|
||
|
|
// A phone can be registered in both portals without linking their identities.
|
||
|
|
customer := *f.s
|
||
|
|
customer.operatorLogin = false
|
||
|
|
_, err = customer.ProvisionCustomerPhone(f.ctx, f.web.UserID, f.user.Issuer, f.user.Subject, f.phone, "CustomerOnly123!", uuid.NewString())
|
||
|
|
must(t, err)
|
||
|
|
if _, err = customer.AccountPrincipal(f.ctx, account.Value, csrf, true); err == nil {
|
||
|
|
t.Fatal("operator token crossed into customer table")
|
||
|
|
}
|
||
|
|
cf := *f
|
||
|
|
cf.fixture = &fixture{}
|
||
|
|
*cf.fixture = *f.fixture
|
||
|
|
cf.s = &customer
|
||
|
|
cf.newLoginSession()
|
||
|
|
cc, ccsrf := operatorPasswordSession(t, &cf, "CustomerOnly123!")
|
||
|
|
if _, err = f.s.AccountPrincipal(f.ctx, cc.Value, ccsrf, true); err == nil {
|
||
|
|
t.Fatal("customer token granted operator access")
|
||
|
|
}
|
||
|
|
|
||
|
|
f.newLoginSession()
|
||
|
|
second, _ := operatorPasswordSession(t, f, "OperatorOnly123!")
|
||
|
|
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ChangedOperator123!", CurrentPassword: "OperatorOnly123!"}, account, csrf, "")
|
||
|
|
if w.Code != 200 {
|
||
|
|
t.Fatal(w.Body.String())
|
||
|
|
}
|
||
|
|
if _, err = f.s.AccountPrincipal(f.ctx, second.Value, "", false); err == nil {
|
||
|
|
t.Fatal("password change kept another operator session")
|
||
|
|
}
|
||
|
|
if _, err = customer.AccountPrincipal(f.ctx, cc.Value, "", false); err != nil {
|
||
|
|
t.Fatal("operator password change revoked customer", err)
|
||
|
|
}
|
||
|
|
w, _ = f.request("POST", "/api/v1/web/auth/logout", struct{}{}, account, csrf, "")
|
||
|
|
if w.Code != 200 {
|
||
|
|
t.Fatal(w.Body.String())
|
||
|
|
}
|
||
|
|
if _, err = f.s.AccountPrincipal(f.ctx, account.Value, "", false); err == nil {
|
||
|
|
t.Fatal("operator logout not revoked")
|
||
|
|
}
|
||
|
|
if _, err = customer.AccountPrincipal(f.ctx, cc.Value, "", false); err != nil {
|
||
|
|
t.Fatal("operator logout revoked customer", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOperatorSMSOnlyAllowsProvisionedActiveOperators(t *testing.T) {
|
||
|
|
f := newOperatorLoginFixture(t)
|
||
|
|
if _, err := f.s.ProvisionOperatorPhone(f.ctx, f.web.UserID, f.user.Issuer, f.user.Subject, f.phone, "OperatorOnly123!", uuid.NewString()); err == nil {
|
||
|
|
t.Fatal("customer promoted by provisioning")
|
||
|
|
}
|
||
|
|
id := f.send()
|
||
|
|
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("unregistered operator accepted", w.Body.String())
|
||
|
|
}
|
||
|
|
var count int
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM operator_phone_credential WHERE phone_digest=$1`, domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone)).Scan(&count))
|
||
|
|
if count != 0 {
|
||
|
|
t.Fatal("SMS registered operator")
|
||
|
|
}
|
||
|
|
_, err := f.s.ProvisionOperatorPhone(f.ctx, f.operatorID, f.p.Issuer, f.p.Subject, f.phone, "OperatorOnly123!", uuid.NewString())
|
||
|
|
must(t, err)
|
||
|
|
w, data := 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(w.Body.String())
|
||
|
|
}
|
||
|
|
var account *http.Cookie
|
||
|
|
for _, c := range w.Result().Cookies() {
|
||
|
|
if c.Name == f.s.cookieName(true) {
|
||
|
|
account = c
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if account == nil {
|
||
|
|
t.Fatal("missing operator SMS session")
|
||
|
|
}
|
||
|
|
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ResetOperator123!"}, account, data["csrf_token"].(string), "")
|
||
|
|
if w.Code != 200 {
|
||
|
|
t.Fatal("SMS reset failed", w.Body.String())
|
||
|
|
}
|
||
|
|
_, err = f.s.pool.Exec(f.ctx, `UPDATE user_account SET status='disabled' WHERE id=$1::uuid`, f.operatorID)
|
||
|
|
must(t, err)
|
||
|
|
t.Cleanup(func() {
|
||
|
|
_, _ = f.s.pool.Exec(f.ctx, `UPDATE user_account SET status='active' WHERE id=$1::uuid`, f.operatorID)
|
||
|
|
})
|
||
|
|
if _, err = f.s.AccountPrincipal(f.ctx, account.Value, "", false); err == nil {
|
||
|
|
t.Fatal("disabled operator session accepted")
|
||
|
|
}
|
||
|
|
}
|