# 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
: 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" echo " two-factor sign-in" # totp [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 [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"