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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user