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:
Thanakorn
2026-09-24 14:53:40 +07:00
parent ae98dcdcdd
commit 73c680e844
16 changed files with 538 additions and 282 deletions
+36 -29
View File
@@ -21,7 +21,9 @@
* 4. Check both conditions that must be true for the OTP to be valid:
* a. The submitted OTP matches the re-derived expected value.
* b. The elapsed time since otpTime is ≤ 5 minutes.
* Fail either → return "Wrong OTP! Please try again."
* Fail either → HTTP 401 "Wrong OTP! Please try again." After
* LOGIN_OTP_MAX_ATTEMPTS wrong codes the pending login is cleared and the
* user must enter the password again (code "login_restart").
* 5. On success:
* a. Concurrent-session check — if the account already has a session_token
* set and session_last_seen is within SESSION_ACTIVE_GRACE_SECONDS
@@ -42,7 +44,7 @@
* cannot forge a valid OTP without also knowing the password hash.
*
* Session keys read:
* login_data['username'], login_data['password'], login_user_id, otpTime
* login_user_id, password_verified_at, otpTime, otp_attempts, skip_otp
*
* Session keys written:
* login_status, login_username, login_name, login_surname, login_company_id,
@@ -50,7 +52,8 @@
*
* Response JSON:
* On success: { "success": 1, "message": "Login Complete!" }
* On failure: { "message": "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)" }
* On failure: { "message": "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)" } — HTTP 401
* (429 when throttled, 409 when signed in on another device)
*/
require_once '../../../session.php';
@@ -59,11 +62,21 @@ require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/otp_policy.php';
require_once '../../../assets/utils/rate_limit.php';
require_once '../login_helpers.php';
rate_limit_guard($pdo1, [
['login_confirm_ip', rate_limit_client_ip(), 60, 900],
]);
// ── Step 1: Load session state written by login_otp.php ───────────────────────
$data["username"] = $_SESSION["login_data"]['username'];
$data["password"] = $_SESSION["login_data"]['password'];
$user_id = $_SESSION["login_user_id"];
// A pending login exists only after login_otp.php verified the password, and
// only for LOGIN_PENDING_SECONDS; anything else must start again from step 1.
if (!login_pending_valid()) {
$_SESSION = [];
login_fail(401, 'Your sign-in has expired. Please enter your username and password again.', ['code' => 'login_restart']);
}
$user_id = (int)$_SESSION["login_user_id"];
// ── Step 2: Fetch user record — need password hash to re-derive the OTP ───────
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
@@ -74,18 +87,7 @@ $temp = $sth->fetch(PDO::FETCH_ASSOC);
// Uses $_SESSION['otpTime'] (set when the OTP was generated) as the TOTP
// counter base. This is the same algorithm used in login_otp.php and
// request_new_otp.php — any change to one must be reflected in all three.
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
$counter = floor($_SESSION["otpTime"] / $time_step);
$data = pack("NN", 0, $counter);
$hash = hash_hmac('sha1', $data, $sercet_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);
}
$otp = generateOTP($temp["password"]);
$otp = login_generate_otp((string)($temp["password"] ?? ''), (int)($_SESSION["otpTime"] ?? 0));
// ── Step 3b: Calculate elapsed time since OTP was issued ──────────────────────
// otpTime is the Unix timestamp stored by login_otp.php when the OTP was sent.
@@ -95,10 +97,6 @@ $now = time();
$otp_diff_seconds = max(0, $now - $otp_time);
$otp_diff_minutes = $otp_diff_seconds / 60.0;
// Store for debug convenience — visible in $_SESSION on the session inspect page
$_SESSION["now"] = $now;
$_SESSION["diff"] = $otp_diff_minutes;
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
// so they never receive or enter an OTP. Admin/owner always go through this check,
@@ -109,9 +107,15 @@ if (empty($_SESSION['skip_otp'])) {
if (!empty($user_id)) {
otp_log_bypass($user_id, 'login_confirm');
}
} elseif ($data["otp"] != $otp || $otp_diff_minutes > 5) {
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
exit(json_encode($answer));
} elseif (!hash_equals($otp, trim((string)($data["otp"] ?? ''))) || $otp_diff_minutes > 5) {
// Count wrong codes per issued OTP; the 6-digit code must not be
// guessable by brute force within its 5-minute window.
$_SESSION['otp_attempts'] = (int)($_SESSION['otp_attempts'] ?? 0) + 1;
if ($_SESSION['otp_attempts'] >= LOGIN_OTP_MAX_ATTEMPTS) {
$_SESSION = [];
login_fail(401, 'Too many incorrect OTP attempts. Please sign in again.', ['code' => 'login_restart']);
}
login_fail(401, "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)");
}
}
@@ -157,8 +161,7 @@ $sth_active->execute([':uid' => $user_id]);
$active_row = $sth_active->fetch(PDO::FETCH_ASSOC);
if (!empty($active_row['session_token']) && !empty($active_row['is_active'])) {
$answer['message'] = 'This account is currently signed in on another device. Please sign out from that session first.';
exit(json_encode($answer));
login_fail(409, 'This account is currently signed in on another device. Please sign out from that session first.');
}
// ── Step 4c: Claim session ────────────────────────────────────────────────────
@@ -179,8 +182,8 @@ $sth_claim->execute([
]);
if ($sth_claim->rowCount() !== 1) {
$answer["message"] = "Login failed: user record not found.";
exit(json_encode($answer));
$_SESSION = [];
login_fail(401, "Login failed: user record not found.", ['code' => 'login_restart']);
}
// ── Step 5a: Regenerate session ID ────────────────────────────────────────────
@@ -189,6 +192,10 @@ if ($sth_claim->rowCount() !== 1) {
// a session ID before the user logs in.
session_regenerate_id(true);
// The pending-login keys are done with once the user is signed in.
unset($_SESSION['login_data'], $_SESSION['password_verified_at'], $_SESSION['otp_attempts'],
$_SESSION['otp_resends'], $_SESSION['reference'], $_SESSION['skip_otp']);
// ── Step 5b: Issue CSRF token ─────────────────────────────────────────────────
// A fresh 256-bit token is generated here and stored in session. All subsequent
// POST requests from the authenticated app must include this token in the