426 lines
15 KiB
Go
426 lines
15 KiB
Go
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())
|
||
|
|
}
|
||
|
|
}
|