Files

222 lines
8.1 KiB
Go
Raw Permalink Normal View History

package onboarding
import (
"context"
"encoding/json"
"github.com/google/uuid"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type fakeWeChat struct{ openID, phone string }
func (f *fakeWeChat) OpenID(context.Context, string) (string, error) { return f.openID, nil }
func (f *fakeWeChat) Phone(context.Context, string) (string, error) { return f.phone, nil }
func TestWeChatProviderExchangeCacheRefreshAndErrors(t *testing.T) {
tokens, phones := 0, 0
mode := "ok"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/sns/jscode2session":
if r.URL.Query().Get("js_code") != "login" || r.URL.Query().Get("appid") != "wx-test" {
t.Error("bad login request")
}
if mode == "badlogin" {
json.NewEncoder(w).Encode(map[string]any{"errcode": 40029, "errmsg": "secret-value"})
return
}
json.NewEncoder(w).Encode(map[string]string{"openid": "openid-test"})
case "/cgi-bin/stable_token":
tokens++
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
if r.Method != "POST" || body["secret"] != "secret-value" {
t.Error("bad token request")
}
json.NewEncoder(w).Encode(map[string]any{"access_token": "token-" + strings.Repeat("x", tokens), "expires_in": 7200})
case "/wxa/business/getuserphonenumber":
phones++
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["code"] != "phone-code" || r.URL.Query().Get("access_token") == "" {
t.Error("bad phone request")
}
if mode == "refresh" && phones == 3 {
json.NewEncoder(w).Encode(map[string]int{"errcode": 40014})
return
}
if mode == "denied" {
json.NewEncoder(w).Encode(map[string]int{"errcode": 48001})
return
}
appID := "wx-test"
if mode == "watermark" {
appID = "other-app"
}
json.NewEncoder(w).Encode(map[string]any{"phone_info": map[string]any{"purePhoneNumber": "13800000000", "countryCode": "86", "watermark": map[string]string{"appid": appID}}})
default:
t.Error("unexpected endpoint")
w.WriteHeader(404)
}
}))
defer server.Close()
c := newWeChatClient("wx-test", "secret-value")
c.base = server.URL
id, err := c.OpenID(context.Background(), "login")
must(t, err)
if id != "openid-test" {
t.Fatal(id)
}
for i := 0; i < 2; i++ {
phone, err := c.Phone(context.Background(), "phone-code")
must(t, err)
if phone != "+8613800000000" {
t.Fatal(phone)
}
}
if tokens != 1 {
t.Fatal("token not cached", tokens)
}
mode = "refresh"
_, err = c.Phone(context.Background(), "phone-code")
must(t, err)
if tokens != 2 || phones != 4 {
t.Fatal("refresh retry", tokens, phones)
}
for _, m := range []string{"denied", "watermark", "badlogin"} {
mode = m
if m == "badlogin" {
_, err = c.OpenID(context.Background(), "login")
} else {
_, err = c.Phone(context.Background(), "phone-code")
}
if err == nil || strings.Contains(err.Error(), "secret-value") {
t.Fatal("invalid provider response accepted or secret leaked", m)
}
}
server.Close()
_, err = c.OpenID(context.Background(), "login")
if err == nil || strings.Contains(err.Error(), "secret-value") {
t.Fatal("network error leaked credentials")
}
}
func TestWeChatLoginPreservesAccountAndRejectsConflicts(t *testing.T) {
f := newLoginFixture(t)
account, csrf := f.smsAccount()
original, err := f.s.AccountPrincipal(f.ctx, account.Value, csrf, true)
must(t, err)
provider := &fakeWeChat{openID: uuid.NewString(), phone: f.phone}
f.s.options.WeChat = provider
f.s.options.WeChatAppID = "wx4553119e05f11660"
login := func(portal string) string {
s, d := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": portal})
if s != 200 {
t.Fatal(s)
}
return "MiniLogin " + d["login_token"].(string)
}
auth := login("customer")
status, data := miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "first"})
if status != 200 || data["phone_required"] != true || data["access_token"] != nil {
t.Fatal("first login bypassed phone", status)
}
status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "second", PhoneCode: "phone"})
if status != 200 {
t.Fatal(status)
}
check := func(data map[string]any) {
token := strings.TrimPrefix(data["access_token"].(string), "customer.")
p, err := f.s.AccountPrincipal(f.ctx, token, "", false)
must(t, err)
if p.Issuer != original.Issuer || p.Subject != original.Subject {
t.Fatal("account changed")
}
var verified bool
must(t, f.s.pool.QueryRow(f.ctx, `SELECT phone_verified_at IS NOT NULL FROM onboarding_account_session WHERE token_hash=$1`, digest([]byte(token))).Scan(&verified))
if verified {
t.Fatal("login granted password reset")
}
}
check(data)
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "replay"})
if status != 401 {
t.Fatal("session replay", status)
}
status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", login("customer"), WeChatLoginInput{LoginCode: "next"})
if status != 200 {
t.Fatal(status)
}
check(data)
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", login("operator"), WeChatLoginInput{LoginCode: "operator"})
if status != 403 {
t.Fatal("operator accepted", status)
}
provider.openID = uuid.NewString()
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", login("customer"), WeChatLoginInput{LoginCode: "other", PhoneCode: "phone"})
if status != 409 {
t.Fatal("phone conflict accepted", status)
}
provider.openID = uuid.NewString()
f.s.options.SelfSignupTenantIDs = nil
provider.phone = "+8613900000001"
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", login("customer"), WeChatLoginInput{LoginCode: "new", PhoneCode: "phone"})
if status != 403 {
t.Fatal("unconfigured signup accepted", status)
}
}
func TestWeChatPasswordLinkAtomicAndLaterQuickLogin(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)
}
provider := &fakeWeChat{openID: uuid.NewString(), phone: f.phone}
f.s.options.WeChat = provider
f.s.options.WeChatAppID = "wx4553119e05f11660"
start := func() (string, string) {
status, data := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "customer"})
if status != 200 {
t.Fatal(status)
}
auth := "MiniLogin " + data["login_token"].(string)
_, token, _ := miniCredential(auth, "MiniLogin")
return auth, digest([]byte(token))
}
auth, hash := start()
in := WeChatPasswordInput{PasswordLoginInput: PasswordLoginInput{Phone: f.phone, Password: "Wrong123!", CaptchaID: f.knownCaptchaFor(hash), CaptchaCode: "654321", TrustedDeviceToken: f.trusted.Value}, LoginCode: "link"}
status, _ := miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat-link", auth, in)
if status != 401 {
t.Fatal(status)
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM customer_wechat_identity WHERE open_id=$1`, provider.openID).Scan(&count))
if count != 0 {
t.Fatal("wrong password linked identity")
}
in.Password = "LocalOnly123!"
in.CaptchaID = f.knownCaptchaFor(hash)
status, data := miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat-link", auth, in)
if status != 200 || data["access_token"] == nil {
t.Fatal("password link failed", status)
}
auth, _ = start()
status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "later"})
if status != 200 || data["access_token"] == nil {
t.Fatal("quick login failed", status)
}
_, err := f.s.pool.Exec(f.ctx, `UPDATE user_account SET status='disabled' WHERE oidc_issuer='urn:platform:onboarding:sms' AND oidc_subject=$1`, domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone))
must(t, err)
auth, _ = start()
status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "disabled"})
if status != 403 {
t.Fatal("disabled user accepted", status)
}
}