#!/usr/bin/env bash # Two-factor: enrolment, that the password alone stops working once it is on, recovery codes, # single use, and that nobody can turn it off without the password. set -u B=${BASE:-http://127.0.0.1:3111} # Refuses early, with the fix, when the server under test is in production mode with # Turnstile refusing every auth route — otherwise the first signup fails and every check # after it reports a security-check error instead of what it was testing. . "$(dirname "$0")/e2e-preflight.sh"; e2e_preflight "$B" T=${TMP:-/tmp}; J="$T/tc-2fa-cj.txt"; rm -f "$J" PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); echo " ✓ $1"; } fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; } check(){ local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then ok "$name"; else fail "$name" "$(echo "$out" | head -c 220)"; fi; } no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 220)"; else ok "$name"; fi; } post() { curl -s -b "$J" -c "$J" -X POST "$B$1" -H 'content-type: application/json' -H "origin: $B" -d "$2"; } py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; } TS=$(date +%s) EMAIL="tfa$TS@example.com" echo "== setup" check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \ -H "x-forwarded-for: 10.19.$((RANDOM%250)).$((RANDOM%250))" \ -d "{\"first\":\"Tess\",\"last\":\"Two\",\"facility\":\"TwoFactor Hospital $TS\",\"email\":\"$EMAIL\",\"password\":\"password123\"}")" '"ok":true' check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true' check "starts off" "$(curl -s -b "$J" "$B/api/2fa")" '"enabled":false' echo "== enrolment" SETUP=$(post /api/2fa '{"action":"setup"}') check "setup returns a secret" "$SETUP" '"secret"' check "and a QR svg" "$SETUP" '/dev/null | tail -1) check "a code was generated for the test" "$CODE" '^[0-9]\{6\}$' ENABLED=$(post /api/2fa "{\"action\":\"enable\",\"code\":\"$CODE\"}") check "the right code turns it on" "$ENABLED" '"ok":true' check "and hands back recovery codes" "$ENABLED" '"codes"' RCODE=$(echo "$ENABLED" | py "print(d['codes'][0])") # Distinct, and shaped like the codes the person is told to write down. A bare count is just as # happy with the same string handed back ten times, which is one recovery code, not ten. check "ten of them, all different" "$(echo "$ENABLED" | py "import re; print(len({c for c in d['codes'] if re.fullmatch(r'[0-9A-F]{5}-[0-9A-F]{5}', c)}))")" '^10$' STATE=$(curl -s -b "$J" "$B/api/2fa") check "now enabled" "$STATE" '"enabled":true' check "and ten of them are stored" "$STATE" '"recoveryLeft":10' echo "== the password alone no longer signs in" # Its own jar, because what matters here is what the first step does NOT hand out. A route that # set the session cookie before returning need2fa — the password alone letting you in, which is the # whole thing this section is named for — would answer with exactly the same body, so the body is # no evidence. The cookie is. NJ="$T/tc-2fa-nosess.txt"; rm -f "$NJ" LOGIN=$(curl -s -c "$NJ" -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$EMAIL\",\"password\":\"password123\"}") check "login asks for a second factor" "$LOGIN" '"need2fa":true' no "and issues no session cookie" "$(cat "$NJ" 2>/dev/null || true)" 'tc_session' check "and grants no session" "$(curl -s -b "$NJ" "$B/api/2fa")" 'Not signed in' TICKET=$(echo "$LOGIN" | py "print(d['ticket'])") # Payload, dot, and a 43-character MAC. Checked because the forgery below is built by mangling this # string, and a mangled empty string is refused for reasons of its own. check "a ticket was handed to the second step" "$TICKET" '^[A-Za-z0-9_-]\{8,\}\.[A-Za-z0-9_-]\{43\}$' echo "== the second step" check "a wrong code is refused" "$(curl -s -X POST "$B/api/auth/2fa" -H 'content-type: application/json' -H "origin: $B" -d "{\"ticket\":\"$TICKET\",\"code\":\"000000\"}")" "isn't right" # Not "bogus.ticket": that is malformed, and readTicket throws it out on a length mismatch before # any signature is compared, so it is refused just as readily by a server that checks no signature # at all. A forgery is well formed and wrong only in its MAC — letters rotated, length kept — so # the MAC comparison is the thing under test. Delete that comparison and this ticket is accepted, # the account is found, and the answer becomes "that code isn't right" instead. FORGED="${TICKET%.*}.$(printf %s "${TICKET##*.}" | tr 'A-Za-z' 'N-ZA-Mn-za-m')" check "a forged ticket is refused" "$(curl -s -X POST "$B/api/auth/2fa" -H 'content-type: application/json' -H "origin: $B" -d "{\"ticket\":\"$FORGED\",\"code\":\"123456\"}")" 'expired' echo "== a recovery code works, once" R1=$(curl -s -X POST "$B/api/auth/2fa" -H 'content-type: application/json' -H "origin: $B" -d "{\"ticket\":\"$TICKET\",\"code\":\"$RCODE\"}") check "recovery code signs in" "$R1" '"ok":true' check "and is reported as used" "$R1" '"usedRecovery":true' check "nine left" "$R1" '"recoveryLeft":9' LOGIN2=$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$EMAIL\",\"password\":\"password123\"}") TICKET2=$(echo "$LOGIN2" | py "print(d['ticket'])") check "the same recovery code is refused a second time" "$(curl -s -X POST "$B/api/auth/2fa" -H 'content-type: application/json' -H "origin: $B" -d "{\"ticket\":\"$TICKET2\",\"code\":\"$RCODE\"}")" "isn't right" echo "== turning it off needs the password" check "wrong password refused" "$(post /api/2fa '{"action":"disable","password":"nope"}')" "isn't right" check "still on" "$(curl -s -b "$J" "$B/api/2fa")" '"enabled":true' check "right password turns it off" "$(post /api/2fa '{"action":"disable","password":"password123"}')" '"ok":true' check "now off" "$(curl -s -b "$J" "$B/api/2fa")" '"enabled":false' check "password alone signs in again" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$EMAIL\",\"password\":\"password123\"}")" '"ok":true' echo "== signed out cannot manage it" check "unauthenticated refused" "$(curl -s "$B/api/2fa")" 'Not signed in' echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]