Files
threadcount-community/scripts/e2e-reset.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

128 lines
9.0 KiB
Bash

#!/usr/bin/env bash
# Password reset: the happy path, and the ways it must refuse.
# Runs without SMTP configured — the token is read from the database, which is exactly what an
# attacker cannot do, so the checks here are about the token's lifecycle rather than the email.
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-rst-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 200)"; fi; }
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 200)"; else ok "$name"; fi; }
post() { curl -s -X POST "$B$1" -H 'content-type: application/json' -H "origin: $B" -d "$2"; }
TS=$(date +%s)
EMAIL="rst$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.13.$((RANDOM%250)).$((RANDOM%250))" \
-d "{\"first\":\"Rita\",\"last\":\"Reset\",\"facility\":\"Reset Hospital $TS\",\"email\":\"$EMAIL\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
echo "== asking for a link tells you nothing about the address"
# The route would give the game away by shape long before it gave it away in words — a 404, or an
# {"error":...} for an address it has never heard of, says "no account here" as plainly as any
# sentence would. So the two replies are compared byte for byte, status code included, rather than
# read for particular turns of phrase.
forgot() { curl -s -w '\n%{http_code}' -X POST "$B/api/auth/forgot" -H 'content-type: application/json' -H "origin: $B" -d "$1"; }
KNOWN=$(forgot "{\"email\":\"$EMAIL\"}")
UNKNOWN=$(forgot '{"email":"nobody-at-all@example.com"}')
check "a known address gets ok" "$KNOWN" '"ok":true'
if [ "$KNOWN" = "$UNKNOWN" ]; then ok "an unknown address gets a byte-identical reply"
else fail "an unknown address gets a byte-identical reply" "$KNOWN vs $UNKNOWN"; fi
check "a malformed address is accepted silently too" "$(post /api/auth/forgot '{"email":"not-an-email"}')" '"ok":true'
echo "== a request actually records a row"
# reset-token.cjs prints "<id> <tokenHash> <expiresAt> <usedAt>", with usedAt as "-" when unspent.
# A row on its own proves nothing: /api/auth/forgot supersedes the outstanding rows and creates the
# new one inside one transaction, and in the wrong order that would stamp the fresh row used — every
# emailed link born dead, with a row in the table to show for it. So the row has to be a spendable one.
ROW=$(node scripts/reset-token.cjs "$EMAIL" 2>/dev/null)
check "asking for a link records a reset" "$ROW" '.'
check "and the reset it records is unspent" "$ROW" ' -$'
check "and has not already expired" "$(echo "$ROW" | awk -v now="$(date -u +%Y-%m-%dT%H:%M:%S)" '{ print ($3 > now) ? "future" : "past" }')" '^future$'
echo "== the happy path"
TOKEN=$(node scripts/reset-mint.cjs "$EMAIL" 2>/dev/null)
check "a token can be minted for the test" "$TOKEN" '.'
# What the table holds for a token whose raw value we know. The design rests on a pg_dump of
# PasswordReset being useless to whoever reads it: the row carries the SHA-256, and the raw token
# exists only in the email. Both halves are worth saying — the stored value is that hash, and the
# raw token appears nowhere in the row. The reader projects four columns, so a plaintext column
# added later slips past the second half until reset-token.cjs prints the whole row.
MINTED=$(node scripts/reset-token.cjs "$EMAIL" 2>/dev/null)
check "the stored value is the token's SHA-256" "$MINTED" "$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1)"
# A substring test rather than a grep: a raw token can begin with "-", which grep reads as an option.
case "$MINTED" in
*"$TOKEN"*) fail "the raw token is never stored" "$MINTED" ;;
*) ok "the raw token is never stored" ;;
esac
check "the new password is accepted" "$(post /api/auth/reset "{\"token\":\"$TOKEN\",\"password\":\"brandnewpass456\"}")" '"ok":true'
check "the new password signs in" "$(post /api/auth/login "{\"email\":\"$EMAIL\",\"password\":\"brandnewpass456\"}")" '"ok":true'
check "the old password no longer works" "$(post /api/auth/login "{\"email\":\"$EMAIL\",\"password\":\"password123\"}")" 'doesn'
echo "== a token is single use"
check "the same token a second time is refused" "$(post /api/auth/reset "{\"token\":\"$TOKEN\",\"password\":\"anotherpass789\"}")" 'expired or has already been used'
check "and the password did not change again" "$(post /api/auth/login "{\"email\":\"$EMAIL\",\"password\":\"brandnewpass456\"}")" '"ok":true'
echo "== an expired token is refused"
OLD=$(node scripts/reset-mint.cjs "$EMAIL" -1000 2>/dev/null)
check "an already-expired token is refused" "$(post /api/auth/reset "{\"token\":\"$OLD\",\"password\":\"expiredpass123\"}")" 'expired or has already been used'
echo "== asking again kills the previous link"
T1=$(node scripts/reset-mint.cjs "$EMAIL" 2>/dev/null)
post /api/auth/forgot "{\"email\":\"$EMAIL\"}" > /dev/null
check "the superseded token is dead" "$(post /api/auth/reset "{\"token\":\"$T1\",\"password\":\"supersededpass1\"}")" 'expired or has already been used'
echo "== refusals"
check "a made-up token is refused" "$(post /api/auth/reset '{"token":"totally-made-up","password":"newpassword123"}')" 'expired or has already been used'
check "a short password is refused" "$(post /api/auth/reset '{"token":"whatever","password":"short"}')" 'at least 8'
check "an empty token is refused" "$(post /api/auth/reset '{"token":"","password":"newpassword123"}')" 'incomplete'
echo "== a reset does not walk past the second factor"
# The failure this covers: setting a password used to sign the account straight in, second factor or
# not — a reset link in a stolen mailbox was a complete bypass of the very thing 2FA is for. The
# response now has to be the ticket shape /api/auth/login uses, with no session cookie attached.
# The resets above changed the password, and a session cookie carries a version derived from the
# hash — so the jar from signup is already dead. Sign in again to have a session to enrol with.
curl -s -c "$J" -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" \
-d "{\"email\":\"$EMAIL\",\"password\":\"brandnewpass456\"}" > /dev/null
SETUP=$(curl -s -b "$J" -c "$J" -X POST "$B/api/2fa" -H 'content-type: application/json' -H "origin: $B" -d '{"action":"setup"}')
SECRET=$(echo "$SETUP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('secret',''))" 2>/dev/null)
if [ -n "$SECRET" ]; then
CODE=$(npx tsx -e "import { base32Decode, totp } from './lib/totp'; console.log(totp(base32Decode('$SECRET')));" 2>/dev/null | tail -1)
check "two-factor turned on for the test" "$(curl -s -b "$J" -c "$J" -X POST "$B/api/2fa" -H 'content-type: application/json' -H "origin: $B" -d "{\"action\":\"enable\",\"code\":\"$CODE\"}")" '"ok":true'
TFT=$(node scripts/reset-mint.cjs "$EMAIL" 2>/dev/null)
C="$T/tc-rst-2fa.txt"; rm -f "$C"
OUT=$(curl -s -c "$C" -X POST "$B/api/auth/reset" -H 'content-type: application/json' -H "origin: $B" -d "{\"token\":\"$TFT\",\"password\":\"twofactorpass99\"}")
check "a reset on a 2FA account asks for the code" "$OUT" '"need2fa":true'
no "and does not report a plain sign-in" "$OUT" '"ok":true'
no "and issues no session cookie" "$(cat "$C" 2>/dev/null || true)" 'tc_session'
check "the new password is still set" "$(post /api/auth/login "{\"email\":\"$EMAIL\",\"password\":\"twofactorpass99\"}")" '"need2fa":true'
rm -f "$C"
else
fail "two-factor setup for the reset test" "no secret returned from /api/2fa"
fi
echo "== the pages"
# Anchored on the heading markup: the h1 ends in a full stop and the tab title app/reset/layout.tsx
# sets does not, and that title goes into the head whatever the page component does — including when
# it renders nothing but chrome.
check "/reset renders server-side" "$(curl -s "$B/reset?token=abc")" '>Reset your password\.'
# Someone who copied half a link out of an email should land on the reset page, not on a 404 or a
# bounce to /auth. The sentence that tells them so ("That link is incomplete") sits inside the
# useSearchParams boundary and is client-rendered, out of curl's reach; the page arriving under its
# own name, with its own status, is the server's half of that promise.
check "/reset says what it is even with no token" "$(curl -s "$B/reset")" '>Reset your password\.'
check "and a half-copied link is not bounced away" "$(curl -s -o /dev/null -w '%{http_code}' "$B/reset")" '^200$'
check "the phone sign-in offers a reset" "$(curl -s "$B/m/login")" 'Forgot password'
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]