Files
wms-app/app/login/api/login_helpers.php
T
Thanakorn 73c680e844 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
2026-09-24 14:53:40 +07:00

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)));
}