Harden sign-in and password reset
- 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
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<?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)));
|
||||
}
|
||||
Reference in New Issue
Block a user