2026-10-17 03:13:16 +02:00
# Accounts (completion program R2): an email address is set only with the password and a code sent to it; recovery writes
# only to a verified address, and its link works once; a password change and "sign out everywhere" end every session,
# while a logout ends only its own device; a hash made at fewer iterations is made again at sign-in; recovery codes expire
# through a TTL index. Needs the mailpit peer.
MP = https://mailpit.test:6443
mp() { site mailpit.test -s " $@ " ; }
# mp_text <address>: the newest message sent to the address, its text, or nothing
mp_text() {
local id
id = $( mp " $MP /api/v1/search?query=to: $1 &limit=1" | j "print(d['messages'][0]['ID'] if d and d.get('messages') else '')" )
[ -n " $id " ] && mp " $MP /api/v1/message/ $id " | j "print(d['Text'])"
}
mp_count() { mp " $MP /api/v1/search?query=to: $1 " | j "print(d['messages_count'] if d else 0)" ; }
api() { local jwt = $1 method = $2 path = $3 ; shift 3; curl -s -X " $method " -H "Authorization: Bearer $jwt " -H 'Content-Type: application/json' " $P$path " " $@ " ; }
code_of() { local jwt = $1 method = $2 path = $3 ; shift 3; curl -s -o /dev/null -w '%{http_code}' -X " $method " -H "Authorization: Bearer $jwt " -H 'Content-Type: application/json' " $P$path " " $@ " ; }
login() { curl -s -X POST " $P /clientapi/user/login" -H 'Content-Type: application/json' -d "{\"userName\":\" $1 \",\"password\":\" $2 \"}" | j "print(d['token'])" ; }
echo "accounts"
run = $( date +%s)
user = "acct $run " ; pw = 'Accounts-Pass-1!' ; pw2 = 'Accounts-Pass-2!' ; mail = " $user @example.test"
curl -s -o /dev/null -X POST " $P /clientapi/user/signup" -H 'Content-Type: application/json' -d "{\"userName\":\" $user \",\"password\":\" $pw \"}"
A = $( login " $user " " $pw " )
[ -n " $A " ] && ok "a login for the scenario" || { ko "no login for the scenario" ; return 1; }
echo " an email address, with the password and a code"
[ " $( code_of " $A " POST /clientapi/user/update -d "{\"email\":\" $mail \"}" ) " = 422 ] \
&& ok "the old route changes no address" || ko "the old route still changes the address"
[ " $( code_of " $A " POST /clientapi/user/email -d "{\"email\":\" $mail \",\"password\":\"wrong-Pass-9\"}" ) " = 403 ] \
&& ok "a wrong password asks for no address" || ko "a wrong password was taken"
sentence = $( api " $A " POST /clientapi/user/email -d "{\"email\":\" $mail \",\"password\":\" $pw \"}" )
[ " $( api " $A " GET /clientapi/user/account | j "print(d['pendingEmail'])" ) " = " $mail " ] \
&& ok "the address waits for its code" || ko "the address is not waiting for its code"
until_true 20 '[ -n "$(mp_text "$mail")" ]' && ok "the code was emailed" || ko "no code arrived"
code = $( mp_text " $mail " | grep -oE '\b[0-9]{6}\b' | head -1)
[ " $( code_of " $A " POST /clientapi/user/email/verify -d '{"code":"000000"}' ) " = 400 ] \
&& ok "a wrong code is refused" || ko "a wrong code was taken"
[ " $( api " $A " POST /clientapi/user/email/verify -d "{\"code\":\" $code \"}" | j "print(d['isEmailValidated'] and d['email'])" ) " = " $mail " ] \
&& ok "the code verifies the address" || ko "the code did not verify the address"
other = "acct ${ run } b"
curl -s -o /dev/null -X POST " $P /clientapi/user/signup" -H 'Content-Type: application/json' -d "{\"userName\":\" $other \",\"password\":\" $pw \"}"
O = $( login " $other " " $pw " )
[ " $( api " $O " POST /clientapi/user/email -d "{\"email\":\" $mail \",\"password\":\" $pw \"}" ) " = " $sentence " ] \
&& ok "an address in use gets the same answer" || ko "an address in use is told apart"
sleep 6
[ " $( mp_count " $mail " ) " = 1 ] && ok "no code goes to an address another login verified" || ko "a code went to an address in use"
echo " recovery"
curl -s -o /dev/null -X POST " $P /clientapi/user/recover/password" -H 'Content-Type: application/json' -d "{\"email\":\" $mail \"}"
until_true 20 '[ "$(mp_count "$mail")" = 2 ]' && ok "the recovery link was emailed" || ko "no recovery link arrived"
rc = $( mp_text " $mail " | grep -oE 'rc=[0-9a-f]{64}' | head -1 | cut -d= -f2)
grep -q 'https://decepub.test/password-recovery?rc=' <<< " $( mp_text " $mail " ) " && ok "the link opens decePub" || ko "the link does not open decePub"
[ " $( curl -s -X POST " $P /clientapi/user/recover/valid" -H 'Content-Type: application/json' -d "\" $rc \"" ) " = true ] \
&& ok "the recovery code is valid" || ko "the recovery code is not valid"
B = $( login " $user " " $pw " )
curl -s -o /dev/null -X POST " $P /clientapi/user/recover/update/password" -H 'Content-Type: application/json' \
-d "{\"newPassword\":\" $pw2 \",\"repeatedPassword\":\" $pw2 \",\"recoveryCode\":\" $rc \"}"
[ " $( curl -s -X POST " $P /clientapi/user/recover/valid" -H 'Content-Type: application/json' -d "\" $rc \"" ) " = false ] \
&& ok "the recovery code works once" || ko "the recovery code works twice"
[ " $( code_of " $B " GET /clientapi/user/account) " = 401 ] && ok "recovery ended every session" || ko "a session outlived the recovery"
[ -n " $( login " $user " " $pw2 " ) " ] && ok "the new password signs in" || ko "the new password does not sign in"
unverified = "acct ${ run } c@example.test"
api " $O " POST /clientapi/user/email -d "{\"email\":\" $unverified \",\"password\":\" $pw \"}" >/dev/null
until_true 20 '[ "$(mp_count "$unverified")" = 1 ]'
curl -s -o /dev/null -X POST " $P /clientapi/user/recover/password" -H 'Content-Type: application/json' -d "{\"email\":\" $unverified \"}"
curl -s -o /dev/null -X POST " $P /clientapi/user/recover/password" -H 'Content-Type: application/json' -d "{\"userName\":\" $other \"}"
sleep 6
[ " $( mp_count " $unverified " ) " = 1 ] && ok "no recovery goes to an unverified address" || ko "a recovery went to an unverified address"
echo " sessions"
C = $( login " $user " " $pw2 " ) ; D = $( login " $user " " $pw2 " )
[ " $( code_of " $C " GET /clientapi/user/logout) " = 200 ] && ok "a device signs out" || ko "signing out failed"
[ " $( code_of " $C " GET /clientapi/user/account) " = 401 ] && ok "its token is refused" || ko "its token still works"
[ " $( code_of " $D " GET /clientapi/user/account) " = 200 ] && ok "another device stays signed in" || ko "another device was signed out"
renewed = $( api " $D " GET /clientapi/user/sniff/again | j "print(d['token'])" )
api " $D " GET /clientapi/user/logout >/dev/null
[ " $( code_of " $renewed " GET /clientapi/user/account) " = 401 ] && ok "a renewed token belongs to the session it renewed" || ko "a renewed token outlived its session's logout"
E = $( login " $user " " $pw2 " ) ; F = $( login " $user " " $pw2 " )
changed = $( api " $E " POST /clientapi/user/update/password \
-d "{\"oldPassword\":\" $pw2 \",\"newPassword\":\" $pw \",\"repeatedPassword\":\" $pw \"}" | j "print(d['token'])" )
[ " $( code_of " $F " GET /clientapi/user/account) " = 401 ] && ok "a password change ends the other sessions" || ko "another session outlived a password change"
[ " $( code_of " $changed " GET /clientapi/user/account) " = 200 ] && ok "the device that changed it goes on" || ko "the device that changed it was signed out"
G = $( login " $user " " $pw " )
[ " $( code_of " $G " POST /clientapi/user/logout/everywhere) " = 200 ] && ok "signing out everywhere" || ko "signing out everywhere failed"
[ " $( code_of " $changed " GET /clientapi/user/account) " = 401 ] && [ " $( code_of " $G " GET /clientapi/user/account) " = 401 ] \
&& ok "every session ended" || ko "a session outlived signing out everywhere"
echo " hashes"
old_hash = $( python3 -c "
import base64,hashlib,os
salt=os.urandom(16)
key=hashlib.pbkdf2_hmac('sha512', b' $pw ', salt, 10101, 32)
print(base64.b64encode(key).decode()+'~'+base64.b64encode(salt).decode()+'~10101')" )
podman exec pasture-mongo mongosh --quiet PrivaPub --eval "db.RootUser.updateOne({UserName: ' $user '}, {\$set: {HashedPassword: ' $old_hash '}})" >/dev/null
[ -n " $( login " $user " " $pw " ) " ] && ok "an old hash still signs in" || ko "an old hash no longer signs in"
iterations = $( podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.RootUser.findOne({UserName: ' $user '}).HashedPassword.split('~')[2])" )
[ " $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"
2026-10-17 03:18:19 +02:00
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"