Files
threadcount-community/scripts/e2e-2fa.sh
T
ThreadCount 1bc2de655a ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:45:19 +10:00

99 lines
6.6 KiB
Bash

#!/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" '<svg'
SECRET=$(echo "$SETUP" | py "print(d['secret'])")
check "still not enabled until proven" "$(curl -s -b "$J" "$B/api/2fa")" '"enabled":false'
check "a wrong code is refused" "$(post /api/2fa '{"action":"enable","code":"000000"}')" "isn't right"
# Generated with the app's own TOTP code — the same code RFC 6238's vectors validate in
# scripts/check-totp.ts, so this is exercising the real algorithm rather than a stub.
CODE=$(npx tsx -e "import { base32Decode, totp } from './lib/totp'; console.log(totp(base32Decode('$SECRET')));" 2>/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 ]