Files
story/internal/onboarding/local_slide_captcha_test.go
T

426 lines
15 KiB
Go
Raw Normal View History

package onboarding
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"image"
_ "image/jpeg"
_ "image/png"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func TestLocalSlideRenderAndBounds(t *testing.T) {
for i := 0; i < 8; i++ {
out, target, err := renderSlideCaptcha()
must(t, err)
if out["mode"] != "slide" || out["thumb_x"] != 5 || out["thumb_y"] != target.Y || target.X < 80 || target.X > 258 {
t.Fatal("invalid slide geometry")
}
for _, key := range []string{"x", "y", "target_x", "target_y", "solution", "answer_hash"} {
if _, exists := out[key]; exists {
t.Fatal("solution exposed")
}
}
for _, part := range []struct {
key, prefix string
w, h int
}{
{"image", "data:image/jpeg;base64,", slideWidth, slideHeight},
{"thumb_image", "data:image/png;base64,", slideTileSize, slideTileSize},
} {
encoded := out[part.key].(string)
if !strings.HasPrefix(encoded, part.prefix) {
t.Fatal("unexpected image format")
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(encoded, part.prefix))
must(t, err)
img, _, err := image.Decode(bytes.NewReader(raw))
must(t, err)
if img.Bounds().Dx() != part.w || img.Bounds().Dy() != part.h {
t.Fatal("incorrect image bounds")
}
if len(raw) > 200000 {
t.Fatal("unbounded captcha payload")
}
}
}
}
func TestLocalSlideParallelRenderer(t *testing.T) {
var wg sync.WaitGroup
var rendered atomic.Int32
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
out, _, err := renderSlideCaptcha()
if err == nil {
if out["mode"] != "slide" {
t.Error("mode")
}
rendered.Add(1)
return
}
if e, ok := err.(*Error); !ok || e.Code != "CAPTCHA_BUSY" {
t.Error(err)
}
}()
}
wg.Wait()
if rendered.Load() < 1 {
t.Fatal("no render completed")
}
}
func TestLocalSlideDecoyGeometry(t *testing.T) {
for x := 80; x <= 258; x++ {
positions, err := slideGapPositions(slideSolution{X: x, Y: 20})
must(t, err)
if len(positions) != 3 || positions[0] != x {
t.Fatal("target lost")
}
for i, gap := range positions {
if gap < 10 || gap > 258 {
t.Fatal("gap outside image")
}
for _, other := range positions[:i] {
if gap-other < slideTileSize+10 && other-gap < slideTileSize+10 {
t.Fatal("overlapping gaps")
}
}
}
}
}
func newSlideLoginFixture(t *testing.T) *loginFixture {
f := newLoginFixture(t)
setCaptchaTestPolicy(t, f.fixture, "slide", "image")
return f
}
func (f *loginFixture) slideChallenge() (map[string]any, slideSolution) {
f.t.Helper()
w, ch := f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
if w.Code != 200 || ch["mode"] != "slide" {
f.t.Fatal("slide generation failed", w.Body.String())
}
value, err := f.s.redis.Get(f.ctx, f.s.captchaKey(digest([]byte(f.cookie.Value)))).Result()
must(f.t, err)
var stored storedCaptcha
must(f.t, json.Unmarshal([]byte(value), &stored))
plain, err := f.s.crypt.open(stored.Solution, slideAAD(digest([]byte(f.cookie.Value)), stored))
must(f.t, err)
var target slideSolution
must(f.t, json.Unmarshal(plain, &target))
return ch, target
}
func slideInput(ch map[string]any, target slideSolution) map[string]any {
return map[string]any{"captcha_id": ch["captcha_id"], "x": target.X, "y": target.Y}
}
func TestLocalSlideSuccessTicketSMSAndReplay(t *testing.T) {
f := newSlideLoginFixture(t)
provider := &fakeCaptchaProvider{}
f.s.options.Captcha = provider
ch, target := f.slideChallenge()
w, proof := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 || proof["captcha_id"] != ch["captcha_id"] || proof["expires_at_ms"] != ch["expires_at_ms"] {
t.Fatal("valid position rejected or lifetime extended", w.Body.String())
}
ticket := proof["captcha_ticket"].(string)
if !validB64(ticket, 32) {
t.Fatal("insecure proof")
}
value, err := f.s.redis.Get(f.ctx, f.s.captchaKey(digest([]byte(f.cookie.Value)))).Result()
must(t, err)
var stored storedCaptcha
must(t, json.Unmarshal([]byte(value), &stored))
if len(stored.Solution) != 0 || stored.ProofHash == "" || strings.Contains(value, ticket) {
t.Fatal("proof must be hashed and solution removed")
}
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("position replay accepted")
}
input := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: ch["captcha_id"].(string), CaptchaTicket: ticket}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", input, f.cookie, f.csrf, "")
if w.Code != 200 || f.sender.calls != 1 || provider.calls != 0 {
t.Fatal("local proof did not authorize SMS independently", w.Body.String())
}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", input, f.cookie, f.csrf, "")
if w.Code != 401 || f.sender.calls != 1 {
t.Fatal("proof reused")
}
}
func TestLocalSlidePasswordUsesLocalProof(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.Body.String())
}
setCaptchaTestPolicy(t, f.fixture, "slide", "image")
provider := &fakeCaptchaProvider{}
f.s.options.Captcha = provider
f.newLoginSession()
ch, target := f.slideChallenge()
w, proof := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
w, _ = f.request("POST", "/api/v1/web/login/password", PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!", CaptchaID: ch["captcha_id"].(string), CaptchaTicket: proof["captcha_ticket"].(string)}, f.cookie, f.csrf, "")
if w.Code != 200 || provider.calls != 0 {
t.Fatal("password did not use local proof", w.Body.String())
}
}
func TestLocalSlidePolicyAuditAndMiniRestriction(t *testing.T) {
f := newFixture(t)
setCaptchaTestPolicy(t, f, "image", "image")
f.s.options.TokenVerifier = captchaPolicyToken{f.p}
policy, err := f.s.readCaptchaPolicy(f.ctx, f.s.pool)
must(t, err)
call := func(in captchaPolicy) (any, error) {
raw, _ := json.Marshal(in)
r := httptest.NewRequest("POST", "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())
}
if _, err = call(captchaPolicy{SaaS: "slide", Mini: "slide", Version: policy.Version}); err == nil {
t.Fatal("mini slide enabled before release")
}
out, err := call(captchaPolicy{SaaS: "slide", Mini: "image", Version: policy.Version})
must(t, err)
if out.(map[string]any)["saas_mode"] != "slide" {
t.Fatal("slide policy not persisted")
}
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("slide switch missing audit")
}
if _, err = call(captchaPolicy{SaaS: "image", Mini: "image", Version: policy.Version}); err == nil {
t.Fatal("stale policy accepted")
}
}
func TestLocalSlideWrongPositionConsumedAndRefreshProtected(t *testing.T) {
f := newSlideLoginFixture(t)
ch, target := f.slideChallenge()
wrong := target
wrong.X += 20
w, _ := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, wrong), f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("wrong position accepted")
}
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("failed challenge reused")
}
old, oldTarget := f.slideChallenge()
current, currentTarget := f.slideChallenge()
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(old, oldTarget), f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("refreshed challenge accepted")
}
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(current, currentTarget), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal("old request destroyed fresh challenge")
}
key := f.s.captchaKey(digest([]byte(f.cookie.Value)))
oldValue, err := f.s.redis.Get(f.ctx, key).Result()
must(t, err)
f.slideChallenge()
updated, err := f.s.redis.Eval(f.ctx, replaceSlideRecord, []string{key}, oldValue, "invalid overwrite").Int64()
must(t, err)
if updated != 0 {
t.Fatal("delayed proof overwrote a refresh")
}
}
func TestLocalSlideExpiryPolicyClientAndForgery(t *testing.T) {
f := newSlideLoginFixture(t)
hash := digest([]byte(f.cookie.Value))
ch, target := f.slideChallenge()
must(t, f.s.redis.PExpire(f.ctx, f.s.captchaKey(hash), -time.Second).Err())
w, _ := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 401 {
t.Fatal("expired challenge accepted")
}
ch, target = f.slideChallenge()
w, proof := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
mini := context.WithValue(f.ctx, captchaRequestKey{}, captchaRequestInfo{client: "mini_program"})
if f.s.verifyLoginCaptcha(mini, hash, ch["captcha_id"].(string), "", proof["captcha_ticket"].(string), "") == nil {
t.Fatal("cross-client proof accepted")
}
ch, target = f.slideChallenge()
w, proof = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
_, err := f.s.pool.Exec(f.ctx, `UPDATE login_captcha_policy SET version=version+1`)
must(t, err)
if f.s.verifyLoginCaptcha(f.ctx, hash, ch["captcha_id"].(string), "", proof["captcha_ticket"].(string), "") == nil {
t.Fatal("old-policy proof accepted")
}
ch, _ = f.slideChallenge()
if f.s.verifyLoginCaptcha(f.ctx, hash, ch["captcha_id"].(string), "", randomToken(32), "") == nil {
t.Fatal("unverified position authorized proof")
}
ch, target = f.slideChallenge()
w, proof = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
must(t, f.s.redis.PExpire(f.ctx, f.s.captchaKey(hash), -time.Second).Err())
if f.s.verifyLoginCaptcha(f.ctx, hash, ch["captcha_id"].(string), "", proof["captcha_ticket"].(string), "") == nil {
t.Fatal("expired proof accepted")
}
}
func TestLocalSlideHTTPOriginCSRFSessionAndStrictCoordinates(t *testing.T) {
f := newSlideLoginFixture(t)
ch, target := f.slideChallenge()
w, _ := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, "wrong", "")
if w.Code != 403 {
t.Fatal("invalid CSRF accepted")
}
w, session := f.request("GET", "/api/v1/web/login/session", nil, nil, "", "")
other := w.Result().Cookies()[0]
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), other, session["csrf_token"].(string), "")
if w.Code != 401 {
t.Fatal("cross-session position accepted")
}
call := func(raw, origin string) int {
r := httptest.NewRequest("POST", "https://platform.example/api/v1/web/login/captcha/verify", strings.NewReader(raw))
r.RemoteAddr = f.ip
r.AddCookie(f.cookie)
r.Header.Set("Origin", origin)
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-CSRF-Token", f.csrf)
r.Header.Set("X-Request-ID", uuid.NewString())
w := httptest.NewRecorder()
f.mux.ServeHTTP(w, r)
return w.Code
}
raw, _ := json.Marshal(slideInput(ch, target))
if call(string(raw), "https://other.example") != 403 {
t.Fatal("cross-origin accepted")
}
for _, bad := range []string{
`{"captcha_id":"` + ch["captcha_id"].(string) + `","x":1.5,"y":10}`,
`{"captcha_id":"` + ch["captcha_id"].(string) + `","x":10}`,
`{"captcha_id":"` + ch["captcha_id"].(string) + `","x":null,"y":10}`,
`{"captcha_id":"` + ch["captcha_id"].(string) + `","x":10,"y":10,"mode":"image"}`,
} {
if call(bad, "https://platform.example") != 400 {
t.Fatal("malformed coordinates accepted")
}
}
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal("unauthorized request consumed challenge")
}
}
func TestLocalSlideConcurrentProofAndConsumeOnce(t *testing.T) {
f := newSlideLoginFixture(t)
ch, target := f.slideChallenge()
x, y := target.X, target.Y
hash := digest([]byte(f.cookie.Value))
var wg sync.WaitGroup
var winners atomic.Int32
var ticket string
var ticketMu sync.Mutex
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
out, err := f.s.verifySlideCaptcha(f.ctx, hash, SlideCaptchaVerifyInput{ID: ch["captcha_id"].(string), X: &x, Y: &y}, func(tx pgx.Tx) error { return f.s.checkLoginSession(f.ctx, tx, hash) })
if err == nil {
winners.Add(1)
ticketMu.Lock()
ticket = out["captcha_ticket"].(string)
ticketMu.Unlock()
}
}()
}
wg.Wait()
if winners.Load() != 1 {
t.Fatal("concurrent position generated multiple proofs")
}
winners.Store(0)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if f.s.verifyLoginCaptcha(f.ctx, hash, ch["captcha_id"].(string), "", ticket, "") == nil {
winners.Add(1)
}
}()
}
wg.Wait()
if winners.Load() != 1 {
t.Fatal("concurrent proof consumed more than once")
}
}
func TestLocalSlideOperatorAndSetupRoutes(t *testing.T) {
f := newOperatorLoginFixture(t)
setCaptchaTestPolicy(t, f.fixture, "slide", "image")
w, ch := f.request("POST", "/api/v1/web/operator/login/captcha", struct{}{}, f.cookie, f.csrf, "")
if w.Code != 200 || ch["mode"] != "slide" {
t.Fatal("operator slide unavailable")
}
hash := digest([]byte(f.cookie.Value))
value, err := f.s.redis.Get(f.ctx, f.s.captchaKey(hash)).Result()
must(t, err)
var stored storedCaptcha
must(t, json.Unmarshal([]byte(value), &stored))
plain, err := f.s.crypt.open(stored.Solution, slideAAD(hash, stored))
must(t, err)
var target slideSolution
must(t, json.Unmarshal(plain, &target))
w, _ = f.request("POST", "/api/v1/web/operator/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal("operator position rejected", w.Body.String())
}
setup := newSlideLoginFixture(t)
setup.s.options.Enabled = true
token, csrf := randomToken(32), randomToken(32)
_, err = setup.s.pool.Exec(setup.ctx, `UPDATE onboarding_web_session SET token_hash=$1,csrf_hash=$2 WHERE token_hash=$3`, digest([]byte(token)), digest([]byte(csrf)), setup.web.Hash)
must(t, err)
cookie := &http.Cookie{Name: setup.s.cookieName(false), Value: token}
w, ch = setup.request("POST", "/api/v1/web/auth/captcha", struct{}{}, cookie, csrf, "")
if w.Code != 200 || ch["mode"] != "slide" {
t.Fatal("setup slide unavailable", w.Body.String())
}
hash = digest([]byte(token))
value, err = setup.s.redis.Get(setup.ctx, setup.s.captchaKey(hash)).Result()
must(t, err)
must(t, json.Unmarshal([]byte(value), &stored))
plain, err = setup.s.crypt.open(stored.Solution, slideAAD(hash, stored))
must(t, err)
must(t, json.Unmarshal(plain, &target))
w, _ = setup.request("POST", "/api/v1/web/auth/captcha/verify", slideInput(ch, target), cookie, csrf, "")
if w.Code != 200 {
t.Fatal("setup position rejected", w.Body.String())
}
}