- OTP attempt limits, constant-time compare, random reference codes - DB-backed rate limits (429) on sign-in, OTP, reset, register, onboarding - one generic sign-in failure message; reset request no longer reveals accounts - no password kept in the session; real status codes on failures
69 lines
2.8 KiB
PHP
69 lines
2.8 KiB
PHP
<?php
|
|
/**
|
|
* login_helpers.php — shared pieces of the 2-step login flow
|
|
* (login_otp.php → login_confirm.php, with request_new_otp.php for resends).
|
|
*
|
|
* Pending-login session state (written by login_otp.php once the password has
|
|
* been verified; the password itself is never kept in the session):
|
|
* login_data['username'] — normalised username/email the user typed
|
|
* login_user_id — resolved user id
|
|
* password_verified_at — Unix time the password was checked
|
|
* otp_attempts — wrong OTP entries for the current code
|
|
* otp_resends — OTP resends for this pending login
|
|
*/
|
|
|
|
// One answer for unknown username, wrong password and locked account, so the
|
|
// login form cannot be used to find out which accounts exist.
|
|
const LOGIN_GENERIC_FAILURE = 'Incorrect username or password, or the account is temporarily locked.';
|
|
|
|
// A verified password is good for this long before the user must type it again.
|
|
const LOGIN_PENDING_SECONDS = 600;
|
|
|
|
// Wrong OTP entries allowed per issued code; the next one ends the pending login.
|
|
const LOGIN_OTP_MAX_ATTEMPTS = 5;
|
|
|
|
// OTP resends allowed per pending login.
|
|
const LOGIN_OTP_MAX_RESENDS = 3;
|
|
|
|
/**
|
|
* 6-digit TOTP (HMAC-SHA1, 3-minute step) keyed by the user's password hash, so
|
|
* a password change invalidates it. Same algorithm db_auth.php re-derives on
|
|
* every request.
|
|
*/
|
|
function login_generate_otp(string $secret_key, int $otp_time, int $time_step = 180, int $length = 6): string {
|
|
$counter = floor($otp_time / $time_step);
|
|
$data = pack("NN", 0, $counter);
|
|
$hash = hash_hmac('sha1', $data, $secret_key, true);
|
|
$offset = ord(substr($hash, -1)) & 0x0F;
|
|
$value = unpack("N", substr($hash, $offset, 4));
|
|
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
|
|
|
|
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
/**
|
|
* Random 6-letter reference shown on the OTP screen and in the email. It used to
|
|
* be derived from the OTP, which let anyone who saw the reference recover the
|
|
* OTP offline by trying all 10^6 codes; a random value carries no information.
|
|
*/
|
|
function login_random_reference(): string {
|
|
$ref = '';
|
|
for ($i = 0; $i < 6; $i++) {
|
|
$ref .= chr(65 + random_int(0, 25));
|
|
}
|
|
return $ref;
|
|
}
|
|
|
|
/** Whether the session holds a pending login whose password check is still fresh. */
|
|
function login_pending_valid(): bool {
|
|
return !empty($_SESSION['login_user_id'])
|
|
&& !empty($_SESSION['password_verified_at'])
|
|
&& (time() - (int)$_SESSION['password_verified_at']) <= LOGIN_PENDING_SECONDS;
|
|
}
|
|
|
|
/** Answer with an HTTP status and a JSON message, then stop. */
|
|
function login_fail(int $status, string $message, array $extra = []): void {
|
|
http_response_code($status);
|
|
exit(json_encode(array_merge(['success' => 0, 'message' => $message], $extra)));
|
|
}
|