165 lines
5.9 KiB
Go
165 lines
5.9 KiB
Go
package onboarding
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
"encoding/json"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"net/http/httptest"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
type fakeCaptchaProvider struct {
|
||
|
|
calls int
|
||
|
|
client, ip string
|
||
|
|
err error
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *fakeCaptchaProvider) AppID(client string) string {
|
||
|
|
if client == "mini_program" {
|
||
|
|
return "456"
|
||
|
|
}
|
||
|
|
return "123"
|
||
|
|
}
|
||
|
|
func (p *fakeCaptchaProvider) Verify(_ context.Context, client, ticket, randstr, ip string) error {
|
||
|
|
p.calls++
|
||
|
|
p.client = client
|
||
|
|
p.ip = ip
|
||
|
|
return p.err
|
||
|
|
}
|
||
|
|
func setCaptchaTestPolicy(t *testing.T, f *fixture, saas, mini string) {
|
||
|
|
t.Helper()
|
||
|
|
_, err := f.s.pool.Exec(f.ctx, `UPDATE login_captcha_policy SET saas_mode=$1,mini_mode=$2,version=version+1`, saas, mini)
|
||
|
|
must(t, err)
|
||
|
|
t.Cleanup(func() {
|
||
|
|
_, e := f.s.pool.Exec(f.ctx, `UPDATE login_captcha_policy SET saas_mode='image',mini_mode='image',version=version+1`)
|
||
|
|
if e != nil {
|
||
|
|
t.Error(e)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
func TestCaptchaPolicyTencentSendCannotBypassOrReplay(t *testing.T) {
|
||
|
|
f := newLoginFixture(t)
|
||
|
|
p := &fakeCaptchaProvider{}
|
||
|
|
f.s.options.Captcha = p
|
||
|
|
setCaptchaTestPolicy(t, f.fixture, "tencent", "image")
|
||
|
|
w, ch := f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
|
||
|
|
if w.Code != 200 || ch["mode"] != "tencent" || ch["captcha_app_id"] != "123" || ch["image"] != nil {
|
||
|
|
t.Fatal(w.Body.String())
|
||
|
|
}
|
||
|
|
in := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: ch["captcha_id"].(string), CaptchaCode: "654321"}
|
||
|
|
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, "")
|
||
|
|
if w.Code != 401 || p.calls != 0 || f.sender.calls != 0 {
|
||
|
|
t.Fatal("numeric answer bypassed tencent")
|
||
|
|
}
|
||
|
|
_, ch = f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
|
||
|
|
in.CaptchaID = ch["captcha_id"].(string)
|
||
|
|
in.CaptchaTicket = uuid.NewString()
|
||
|
|
in.CaptchaRandstr = "random"
|
||
|
|
in.CaptchaCode = ""
|
||
|
|
req := uuid.NewString()
|
||
|
|
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, req)
|
||
|
|
if w.Code != 200 || p.calls != 1 || p.client != "saas" || p.ip != strings.Split(f.ip, ":")[0] || f.sender.calls != 1 {
|
||
|
|
t.Fatal(w.Body.String())
|
||
|
|
}
|
||
|
|
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, req)
|
||
|
|
if w.Code != 200 || p.calls != 1 || f.sender.calls != 1 {
|
||
|
|
t.Fatal("idempotency failed")
|
||
|
|
}
|
||
|
|
_, ch = f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
|
||
|
|
in.CaptchaID = ch["captcha_id"].(string)
|
||
|
|
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, "")
|
||
|
|
if w.Code != 401 || p.calls != 1 || f.sender.calls != 1 {
|
||
|
|
t.Fatal("ticket replay accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
func TestCaptchaPolicyClientSeparationExpiryAndCloudFailure(t *testing.T) {
|
||
|
|
f := newLoginFixture(t)
|
||
|
|
p := &fakeCaptchaProvider{}
|
||
|
|
f.s.options.Captcha = p
|
||
|
|
setCaptchaTestPolicy(t, f.fixture, "image", "tencent")
|
||
|
|
webctx := context.WithValue(f.ctx, captchaRequestKey{}, captchaRequestInfo{client: "saas", ip: "203.0.113.1"})
|
||
|
|
mini := context.WithValue(f.ctx, captchaRequestKey{}, captchaRequestInfo{client: "mini_program", ip: "203.0.113.2"})
|
||
|
|
hash := digest([]byte(f.cookie.Value))
|
||
|
|
ch, err := f.s.loginCaptcha(mini, hash)
|
||
|
|
must(t, err)
|
||
|
|
if ch["captcha_app_id"] != "456" {
|
||
|
|
t.Fatal("mixed app ids")
|
||
|
|
}
|
||
|
|
if f.s.verifyLoginCaptcha(webctx, hash, ch["captcha_id"].(string), "", uuid.NewString(), "") == nil {
|
||
|
|
t.Fatal("cross-client bypass")
|
||
|
|
}
|
||
|
|
ch, err = f.s.loginCaptcha(mini, hash)
|
||
|
|
must(t, err)
|
||
|
|
_, err = f.s.redis.Expire(f.ctx, f.s.captchaKey(hash), -time.Second).Result()
|
||
|
|
must(t, err)
|
||
|
|
if f.s.verifyLoginCaptcha(mini, hash, ch["captcha_id"].(string), "", uuid.NewString(), "") == nil {
|
||
|
|
t.Fatal("expired accepted")
|
||
|
|
}
|
||
|
|
ch, err = f.s.loginCaptcha(mini, hash)
|
||
|
|
must(t, err)
|
||
|
|
p.err = captchaUnavailable()
|
||
|
|
if f.s.verifyLoginCaptcha(mini, hash, ch["captcha_id"].(string), "", uuid.NewString(), "") == nil || p.client != "mini_program" {
|
||
|
|
t.Fatal("cloud failure accepted")
|
||
|
|
}
|
||
|
|
ch, err = f.s.loginCaptcha(mini, hash)
|
||
|
|
must(t, err)
|
||
|
|
p.err = nil
|
||
|
|
_, err = f.s.pool.Exec(f.ctx, `UPDATE login_captcha_policy SET version=version+1`)
|
||
|
|
must(t, err)
|
||
|
|
if f.s.verifyLoginCaptcha(mini, hash, ch["captcha_id"].(string), "", uuid.NewString(), "") == nil {
|
||
|
|
t.Fatal("old policy accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type captchaPolicyToken struct{ p identity.Principal }
|
||
|
|
|
||
|
|
func (v captchaPolicyToken) Verify(context.Context, string) (identity.Principal, error) {
|
||
|
|
return v.p, nil
|
||
|
|
}
|
||
|
|
func TestCaptchaPolicyAdminOnlyReadinessVersionAndAudit(t *testing.T) {
|
||
|
|
f := newFixture(t)
|
||
|
|
setCaptchaTestPolicy(t, f, "image", "image")
|
||
|
|
f.s.options.TokenVerifier = captchaPolicyToken{f.p}
|
||
|
|
call := func(method string, body any) (any, error) {
|
||
|
|
raw, _ := json.Marshal(body)
|
||
|
|
r := httptest.NewRequest(method, "https://platform.example/api/v1/ops/login-captcha-policy", bytes.NewReader(raw))
|
||
|
|
r.Header.Set("Authorization", "Bearer test")
|
||
|
|
r.Header.Set("Content-Type", "application/json")
|
||
|
|
r.Header.Set("X-Tenant-ID", f.tenantPublic)
|
||
|
|
return f.s.captchaPolicyHTTP(r, uuid.NewString())
|
||
|
|
}
|
||
|
|
out, err := call("GET", nil)
|
||
|
|
must(t, err)
|
||
|
|
policy := out.(map[string]any)
|
||
|
|
in := captchaPolicy{SaaS: "tencent", Mini: "image", Version: policy["version"].(int64)}
|
||
|
|
if _, err = call("POST", in); err == nil {
|
||
|
|
t.Fatal("unconfigured provider accepted")
|
||
|
|
}
|
||
|
|
f.s.options.Captcha = &fakeCaptchaProvider{}
|
||
|
|
out, err = call("POST", in)
|
||
|
|
must(t, err)
|
||
|
|
if out.(map[string]any)["saas_mode"] != "tencent" {
|
||
|
|
t.Fatal("not persisted")
|
||
|
|
}
|
||
|
|
if _, err = call("POST", in); err == nil {
|
||
|
|
t.Fatal("stale version accepted")
|
||
|
|
}
|
||
|
|
var count int
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM audit_event WHERE tenant_id=$1 AND action='login.captcha.policy.update'`, f.tenant).Scan(&count))
|
||
|
|
if count != 1 {
|
||
|
|
t.Fatal("missing audit")
|
||
|
|
}
|
||
|
|
_, err = f.s.pool.Exec(f.ctx, `UPDATE user_account SET platform_role='ops_operator' WHERE id=$1`, f.operatorID)
|
||
|
|
must(t, err)
|
||
|
|
if _, err = call("GET", nil); err == nil {
|
||
|
|
t.Fatal("nonadmin read")
|
||
|
|
}
|
||
|
|
if _, err = call("POST", captchaPolicy{SaaS: "image", Mini: "image", Version: in.Version + 1}); err == nil {
|
||
|
|
t.Fatal("nonadmin write")
|
||
|
|
}
|
||
|
|
}
|