R3 two-factor sign-in: TOTP with recovery codes, asked for only after a correct password
Build / Build (push) Successful in 36s
Build / Build (push) Successful in 36s
Completion program R3 (docs/ROADMAP.md, 2026-10-17). - Services/TwoFactor.cs: TOTP (RFC 6238, 30 s, 6 digits, SHA-1, one step of slack, Otp.NET 1.4.1) and ten recovery codes (SHA-256 kept, pulled when used). RootUser.Totp holds the secret, the last step taken (moved only by a conditional update, so a code works once even for two sign-ins at the same moment) and the codes' hashes. - /clientapi/user/totp/setup (the password) answers the secret, its otpauth:// URI and a QR code as an SVG data URI (QRCoder 1.8.0); /totp/enable takes the app's first code, shows the recovery codes once and ends every other session (this device gets a new one); /totp/disable takes the password and a code. GET /clientapi/user/account says whether it is on and how many recovery codes are left. - Sign-in (/clientapi/user/login, the invitation login, /oauth/login) asks for LoginForm.OneTimeCode with a 428, and only once the password was right; the OAuth page has the field. Recovery by email leaves two-factor on. - PrivaPub admin reset-totp <root>: the way back for a login that lost its authenticator and its codes. - scenarios/accounts.sh checks it all, with codes computed in Python; the OAuth page with its antiforgery token. Verified: dotnet build PrivaPub.sln -c Release; the pasture (gts, mailpit): accounts 45/45. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
cff75e3a47
commit
1f3bb27e17
13 files changed
+372
-5
No files matched your search
@@ -96,3 +96,59 @@ iterations=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db
|
||||
[ "$iterations" != 10101 ] && ok "it was made again at $iterations iterations" || ko "the old hash was kept"
|
||||
ttl=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.EmailRecovery.getIndexes().some(i => i.expireAfterSeconds === 0) && db.EmailVerification.getIndexes().some(i => i.expireAfterSeconds === 0))")
|
||||
[ "$ttl" = true ] && ok "recovery and verification codes expire by TTL" || ko "no TTL on the codes"
|
||||
|
||||
echo " two-factor sign-in"
|
||||
# totp <base32 secret> [step offset]: the authenticator's code now (or a step later)
|
||||
totp() { python3 -c "
|
||||
import base64,hmac,hashlib,struct,time,sys
|
||||
s=sys.argv[1]; s+='='*((8-len(s)%8)%8)
|
||||
k=base64.b32decode(s.upper())
|
||||
t=int(time.time())//30+int(sys.argv[2])
|
||||
h=hmac.new(k,struct.pack('>Q',t),hashlib.sha1).digest()
|
||||
o=h[-1]&15
|
||||
print('%06d'%((struct.unpack('>I',h[o:o+4])[0]&0x7fffffff)%1000000))" "$1" "${2:-0}"; }
|
||||
login2() { curl -s -o /dev/null -w '%{http_code}' -X POST "$P/clientapi/user/login" -H 'Content-Type: application/json' -d "{\"userName\":\"$1\",\"password\":\"$2\",\"oneTimeCode\":\"$3\"}"; }
|
||||
T=$(login "$user" "$pw")
|
||||
[ "$(code_of "$T" POST /clientapi/user/totp/setup -d '{"password":"wrong-Pass-9"}')" = 403 ] && ok "setting it up takes the password" || ko "a wrong password set it up"
|
||||
setup=$(api "$T" POST /clientapi/user/totp/setup -d "{\"password\":\"$pw\"}")
|
||||
secret=$(echo "$setup" | j "print(d['secret'])")
|
||||
[ -n "$secret" ] && echo "$setup" | j "print(d['otpAuthUri'].startswith('otpauth://totp/') and d['qrDataUri'].startswith('data:image/svg+xml;base64,'))" | grep -q True \
|
||||
&& ok "the setup gives the secret, its URI and a QR code" || ko "the setup is incomplete"
|
||||
[ "$(code_of "$T" POST /clientapi/user/totp/enable -d '{"code":"000000"}')" = 400 ] && ok "a wrong first code is refused" || ko "a wrong first code turned it on"
|
||||
enabled=$(api "$T" POST /clientapi/user/totp/enable -d "{\"code\":\"$(totp "$secret")\"}")
|
||||
recovery=$(echo "$enabled" | j "print(d['recoveryCodes'][0])"); spare=$(echo "$enabled" | j "print(d['recoveryCodes'][1])")
|
||||
T2=$(echo "$enabled" | j "print(d['session']['token'])")
|
||||
[ "$(echo "$enabled" | j "print(len(d['recoveryCodes']))")" = 10 ] && ok "turning it on shows ten recovery codes" || ko "no recovery codes"
|
||||
[ "$(code_of "$T" GET /clientapi/user/account)" = 401 ] && [ "$(code_of "$T2" GET /clientapi/user/account)" = 200 ] \
|
||||
&& ok "turning it on ends the other sessions and this device goes on" || ko "turning it on left the sessions as they were"
|
||||
[ "$(login2 "$user" "$pw" "")" = 428 ] && ok "a correct password alone is asked for a code (428)" || ko "a password alone still signs in"
|
||||
[ "$(login2 "$user" "wrong-Pass-9" "")" = 400 ] && ok "a wrong password is not told about two-factor" || ko "a wrong password learns of two-factor"
|
||||
[ "$(login2 "$user" "$pw" "$(totp "$secret")")" = 400 ] && ok "the code that turned it on can't be used again" || ko "a used code signed in"
|
||||
next=$(totp "$secret" 1)
|
||||
[ "$(login2 "$user" "$pw" "$next")" = 200 ] && ok "the next code signs in" || ko "the next code was refused"
|
||||
[ "$(login2 "$user" "$pw" "$next")" = 400 ] && ok "a code is taken once" || ko "a code signed in twice"
|
||||
[ "$(login2 "$user" "$pw" "$recovery")" = 200 ] && ok "a recovery code signs in" || ko "a recovery code was refused"
|
||||
[ "$(login2 "$user" "$pw" "$recovery")" = 400 ] && ok "a recovery code works once" || ko "a recovery code worked twice"
|
||||
[ "$(api "$T2" GET /clientapi/user/account | j "print(d['twoFactorEnabled'] and d['recoveryCodesLeft'])")" = 9 ] \
|
||||
&& ok "nine recovery codes are left" || ko "the recovery codes left are miscounted"
|
||||
# oauth_login <user> <password> [code]: the OAuth sign-in page's answer (with its antiforgery token, as tools/smoke/oauth.sh)
|
||||
oauth_login() {
|
||||
local jar="$work/oauth-jar" xt
|
||||
rm -f "$jar"
|
||||
xt=$(curl -s -c "$jar" -b "$jar" "$P/oauth/login" | grep -o 'name="__RequestVerificationToken" type="hidden" value="[^"]*"' | sed 's/.*value="//;s/"//')
|
||||
curl -s -o "$work/oauth-page" -w '%{http_code}' -c "$jar" -b "$jar" -X POST "$P/oauth/login" --data-urlencode "__RequestVerificationToken=$xt" \
|
||||
--data-urlencode "userName=$1" --data-urlencode "password=$2" --data-urlencode "oneTimeCode=${3:-}"
|
||||
}
|
||||
oauth=$(oauth_login "$user" "$pw")
|
||||
[ "$oauth" = 200 ] && grep -q 'authenticator app' "$work/oauth-page" && ok "the OAuth sign-in page asks for the code" || ko "the OAuth sign-in page answered $oauth without a code"
|
||||
oauth=$(oauth_login "$user" "$pw" "$spare")
|
||||
[ "$oauth" = 302 ] && ok "the OAuth sign-in page takes a code" || ko "the OAuth sign-in page answered $oauth with a code"
|
||||
[ "$(code_of "$T2" POST /clientapi/user/totp/disable -d "{\"password\":\"$pw\",\"code\":\"000000\"}")" = 400 ] \
|
||||
&& ok "turning it off takes a code" || ko "a wrong code turned it off"
|
||||
off=$(api "$T2" POST /clientapi/user/totp/disable -d "{\"password\":\"$pw\",\"code\":\"$(echo "$enabled" | j "print(d['recoveryCodes'][2])")\"}")
|
||||
[ "$(echo "$off" | j "print(d['twoFactorEnabled'])")" = False ] && [ "$(login2 "$user" "$pw" "")" = 200 ] \
|
||||
&& ok "turned off, the password alone signs in again" || ko "two-factor could not be turned off"
|
||||
setup=$(api "$T2" POST /clientapi/user/totp/setup -d "{\"password\":\"$pw\"}"); secret=$(echo "$setup" | j "print(d['secret'])")
|
||||
T3=$(api "$T2" POST /clientapi/user/totp/enable -d "{\"code\":\"$(totp "$secret")\"}" | j "print(d['session']['token'])")
|
||||
podman exec -w /app pasture-privapub /app/PrivaPub admin reset-totp "$user" >/dev/null 2>&1
|
||||
[ "$(login2 "$user" "$pw" "")" = 200 ] && ok "admin reset-totp turns it off from the box" || ko "admin reset-totp did not turn it off"
|
||||
Reference in new issue
Block a user