ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
#!/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 ]
|
||||
Reference in New Issue
Block a user