344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
99 lines
6.6 KiB
Bash
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 ]
|