222 lines
8.1 KiB
Go
222 lines
8.1 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|