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
+1 -1
View File
@@ -38,7 +38,7 @@ $token = $_SESSION['accept_invite_token'];
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
if (empty($csrf) || !hash_equals((string)($_SESSION['csrf_token'] ?? ''), $csrf)) {
http_response_code(403);
$answer['message'] = 'Invalid request.';
exit(json_encode($answer));
+1 -1
View File
@@ -49,7 +49,7 @@ $token = $_SESSION['invited_token'];
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
if (empty($csrf) || !hash_equals((string)($_SESSION['csrf_token'] ?? ''), $csrf)) {
http_response_code(403);
$answer['message'] = 'Invalid request.';
exit(json_encode($answer));
+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
+56 -61
View File
@@ -13,7 +13,8 @@
* 1. Resolve user_id by username or email (case-insensitive).
* 2. Fetch hashed password and full user record.
* 3. Verify submitted password via password_verify().
* 4. On failure → clear cookies, return "Incorrect Password".
* 4. On failure (unknown user, wrong password or locked account) → clear
* cookies, HTTP 401 with one generic message (LOGIN_GENERIC_FAILURE).
* 5. On success → run the following pre-login checks in order:
* a. Email format guard (malformed email → block with message).
* b. Unverified account (status = 'pending'):
@@ -31,17 +32,20 @@
* - Note: 'support' user and 'lord' licence bypass this check.
* e. Licence expiry check: if now > $expire + 1 day → return "expire".
* 6. Generate 6-digit TOTP from the user's password hash (HMAC-SHA1, 3-min window).
* 7. Generate a 6-letter human-readable reference number from the TOTP.
* 7. Generate a random 6-letter reference number (not derived from the OTP).
* 8. If the user's default_company has a company_smtp row → send OTP email.
* If no SMTP configured → skip email, set skip_otp flag in response.
* 9. Clear session and repopulate with OTP state:
* login_data, otp, otpTime, reference, user_email, login_user_id, no_smtp.
* login_data (username only), password_verified_at, otp, otpTime,
* reference, user_email, login_user_id, no_smtp.
* 10. Return { success: 1, skip_otp: bool, message: "Login Complete!" }.
* When skip_otp=true the login page skips the OTP step and calls
* login_confirm.php directly.
*
* Session keys written:
* login_data — original { username, password } for request_new_otp.php
* login_data — { username } only; the password is never stored
* password_verified_at — when the password was checked (request_new_otp.php,
* login_confirm.php require it to be recent)
* otp — the generated TOTP value
* otpTime — Unix timestamp the OTP was generated (used for expiry check)
* reference — 6-letter reference code shown on the OTP screen
@@ -51,7 +55,7 @@
*
* Response JSON:
* On success: { "success": 1, "skip_otp": bool, "message": "Login Complete!" }
* On failure: { "message": "<reason>" }
* On failure: { "message": "<reason>" } with HTTP 401/403 (429 when throttled)
* Special: { "message": "wait" } — device pending whitelist approval
* { "message": "block" } — device is blacklisted
* { "expire": "expire" } — licence has expired
@@ -63,28 +67,36 @@ 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';
$username = strtolower(trim((string)($data["username"] ?? '')));
// ── Step 0: Throttle — per client IP and per account name ────────────────────
// The per-user lockout below only counts real accounts; this also slows
// password spraying across many usernames from one address.
rate_limit_guard($pdo1, [
['login_ip', rate_limit_client_ip(), 30, 900],
['login_user', $username, 15, 900],
]);
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
$sth->execute(array(strtolower($data["username"])));
$sth->execute(array($username));
$user_id = $sth->fetchColumn();
$username = strtolower($data["username"]);
// ── Step 2: Fetch the user's hashed password + lockout state ─────────────────
$sth = $pdo1->prepare("SELECT password, login_attempts, locked_until FROM user WHERE username = ? OR email = ? LIMIT 1;");
$sth->execute(array($username, $username));
$temp = $sth->fetch(PDO::FETCH_ASSOC);
// ── Step 2a: Lockout check — only when the username resolves to a real user ──
// We only block here when $user_id is set (valid username) to avoid leaking
// whether an account exists via a different error message.
// A locked account gets the same generic answer as a wrong password, so the
// lockout cannot be used to confirm that an account exists.
if ($user_id && !empty($temp['locked_until'])) {
if (strtotime($temp['locked_until']) > time()) {
// Still within the lockout window — reject
$retry_at = date('H:i', strtotime($temp['locked_until']));
$answer['message'] = "Too many failed attempts. Please try again after {$retry_at}.";
exit(json_encode($answer));
login_fail(401, LOGIN_GENERIC_FAILURE);
} else {
// Lockout has expired — reset counter so they get a fresh 10 attempts
$pdo1->prepare("UPDATE user SET login_attempts = 0, locked_until = NULL WHERE user_id = :id")
@@ -94,7 +106,7 @@ if ($user_id && !empty($temp['locked_until'])) {
}
// ── Step 3–4: Verify password — exit with error on mismatch ──────────────────
if (password_verify(trim($data["password"]), $temp["password"])) {
if ($temp && password_verify(trim((string)($data["password"] ?? '')), $temp["password"])) {
// ── Reset lockout on successful password verification ─────────────────────
if ($user_id) {
@@ -120,8 +132,8 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
// Blocks accounts with a malformed email (e.g. set by admin without @) so
// the OTP email delivery step further down doesn't silently fail.
if (strpos($user_email, "@") === false) {
$answer["message"] = "<b>" . $user_email . "</b> is not eligible email, please contact your administrator to change your email.";
exit(json_encode($answer));
// The message is rendered as HTML by bootbox — escape the stored value.
login_fail(403, "<b>" . htmlspecialchars((string)$user_email, ENT_QUOTES, 'UTF-8') . "</b> is not eligible email, please contact your administrator to change your email.");
}
// ── Step 5c: Unverified account (status = 'pending') ─────────────────────
@@ -166,6 +178,7 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
'key' => $pinkey,
]);
http_response_code(403);
if ($mail_sent) {
$answer["message"] = "Your email is not verified. We've sent a new verification link to your inbox — please check your email.";
} else {
@@ -177,8 +190,7 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
// ── Step 5d: Deactivated account ─────────────────────────────────────────
if ($r["status"] === "not activated") {
$answer["message"] = "Your account has been deactivated. Please contact your administrator.";
exit(json_encode($answer));
login_fail(403, "Your account has been deactivated. Please contact your administrator.");
}
// ── Step 5e: Secure-login device whitelist check ──────────────────────────
@@ -202,11 +214,13 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
$s->execute(array(":cookie" => $data["cookie"], ":ip" => $_SERVER["REMOTE_ADDR"]));
session_destroy();
http_response_code(403);
$answer["message"] = "wait";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
echo json_encode($answer);
// Stop here: the session is gone, nothing below may run.
exit(json_encode($answer));
} else {
@@ -216,6 +230,7 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
// Device explicitly blocked by admin
session_destroy();
http_response_code(403);
$answer["message"] = "block";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
@@ -223,11 +238,13 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
echo json_encode($answer);
$deviceDecision = ['type' => 'BLOCKED', 'status' => 0];
exit;
} else if ($coo["status"] == "1") {
// Device registered but not yet approved — notify admin
session_destroy();
http_response_code(403);
$answer["message"] = "wait";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
@@ -250,7 +267,9 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
// If the licence expired more than 1 day ago, reject the login.
if (strtotime("now") > strtotime($expire . " + 1 day")) {
session_destroy();
$answer["expire"] = "expire";
http_response_code(403);
$answer["expire"] = "expire";
$answer["message"] = "Your licence has expired. Please contact your administrator.";
exit(json_encode($answer));
}
@@ -273,10 +292,12 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
if (!$requires_otp) {
$_SESSION = [];
$_SESSION['login_data'] = $data;
$_SESSION['login_user_id'] = $user_id;
$_SESSION['otpTime'] = time();
$_SESSION['skip_otp'] = true;
session_regenerate_id(true);
$_SESSION['login_data'] = ['username' => $username];
$_SESSION['password_verified_at'] = time();
$_SESSION['login_user_id'] = $user_id;
$_SESSION['otpTime'] = time();
$_SESSION['skip_otp'] = true;
$answer['success'] = 1;
$answer['skip_otp'] = true;
$answer['message'] = 'Login Complete!';
@@ -287,38 +308,12 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
// The secret key is the user's current password hash, so the OTP is unique
// per user and automatically invalidated if the password changes.
// time_step=180 means the OTP window is 3 minutes (same counter for 3 min).
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
global $otpTime;
$otpTime = time(); // captured globally so it can be stored in session
$counter = floor($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);
}
$otpTime = time();
$otp = login_generate_otp($temp["password"], $otpTime);
// ── Step 7: Generate 6-letter reference number ───────────────────────────
// Converts a second TOTP (derived from the first OTP as key) to a base-26
// uppercase letter string. Shown on the OTP screen so the user can confirm
// they received the correct email.
function numberToLetters($num) {
$result = '';
while ($num > 0) {
$mod = ($num - 1) % 26;
$result = chr(65 + $mod) . $result;
$num = intval(($num - $mod) / 26);
}
return str_pad($result, 6, 'A', STR_PAD_LEFT);
}
$otp = generateOTP($temp["password"]);
$reference_number = numberToLetters(generateOTP($otp));
// Random, shown on the OTP screen so the user can match it to the email.
$reference_number = login_random_reference();
// ── Step 8: Look up company SMTP and send OTP email ──────────────────────
// Uses the SMTP settings saved for the user's default_company.
@@ -371,8 +366,12 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
// The full session is cleared first to prevent session fixation — any data
// from a previous partial login attempt is discarded before writing new state.
$_SESSION = [];
session_regenerate_id(true);
$_SESSION["login_data"] = $data; // preserved for request_new_otp.php resend flow
$_SESSION["login_data"] = ['username' => $username]; // never the password
$_SESSION["password_verified_at"] = time(); // request_new_otp.php / login_confirm.php require it to be recent
$_SESSION["otp_attempts"] = 0;
$_SESSION["otp_resends"] = 0;
$_SESSION["otp"] = $otp; // expected value for login_confirm.php to verify
$_SESSION["otpTime"] = $otpTime; // timestamp for the 5-minute expiry window
$_SESSION["reference"] = $reference_number; // shown on OTP input screen
@@ -394,27 +393,23 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
// ── Password mismatch ─────────────────────────────────────────────────────
// Only increment the counter when the username is valid — wrong usernames
// don't count so a typo in your own name doesn't eat your own attempts.
// Every failure gets the same generic message (no username enumeration).
if ($user_id) {
$attempts = (int)($temp['login_attempts'] ?? 0) + 1;
if ($attempts >= 5) {
$locked_until = date('Y-m-d H:i:s', strtotime('+30 minutes'));
$pdo1->prepare("UPDATE user SET login_attempts = :a, locked_until = :l WHERE user_id = :id")
->execute([':a' => $attempts, ':l' => $locked_until, ':id' => $user_id]);
$retry_at = date('H:i', strtotime($locked_until));
$answer['message'] = "Too many failed attempts. Please try again after {$retry_at}.";
} else {
$pdo1->prepare("UPDATE user SET login_attempts = :a WHERE user_id = :id")
->execute([':a' => $attempts, ':id' => $user_id]);
$answer['message'] = "Incorrect Password";
}
} else {
$answer['message'] = "Incorrect Username";
}
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
exit(json_encode($answer));
login_fail(401, LOGIN_GENERIC_FAILURE);
}
$answer["success"] = 1;
+10 -2
View File
@@ -55,6 +55,8 @@ require_once '../../../config.php';
require_once '../../../dbconn.php';
require_once '../../../assets/utils/db_helpers.php';
require_once '../../../assets/utils/otp_policy.php';
require_once '../../../assets/utils/rate_limit.php';
require_once '../../../assets/utils/secret_box.php';
header('Content-Type: application/json; charset=utf-8');
@@ -85,7 +87,7 @@ if ($sth->fetchColumn() !== 'owner') {
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
if (empty($csrf) || !hash_equals((string)($_SESSION['csrf_token'] ?? ''), $csrf)) {
http_response_code(403);
$answer['message'] = 'Invalid request.';
exit(json_encode($answer));
@@ -94,6 +96,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
// ── Step 2b: Throttle — each attempt sends an SMTP test email ────────────────
rate_limit_guard($pdo1, [
['onboarding_ip', rate_limit_client_ip(), 20, 900],
['onboarding_user', (string)$user_id, 10, 900],
]);
try {
// ── Step 3: Sanitise input ────────────────────────────────────────────────
@@ -147,7 +155,7 @@ try {
// ── Step 7: Encrypt SMTP password ────────────────────────────────────
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
// so the stored password can be decrypted by the mailer module.
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
$encrypted_pass = secret_encrypt($smtp_password, $pinkey);
// Assemble a temporary SMTP config for the test send (step 8)
$smtp_config = [
+8 -1
View File
@@ -48,6 +48,7 @@ require_once '../../../config.php';
require_once '../../../dbconn.php';
require_once '../../../assets/utils/db_helpers.php';
require_once '../../../assets/utils/classes/PasswordManager.php';
require_once '../../../assets/utils/rate_limit.php';
header('Content-Type: application/json; charset=utf-8');
@@ -58,7 +59,7 @@ $answer = ['success' => 0, 'message' => ''];
// stored in session. This prevents cross-site request forgery on the register form.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
if (empty($csrf) || !hash_equals((string)($_SESSION['csrf_token'] ?? ''), $csrf)) {
http_response_code(403);
$answer['message'] = 'Invalid request.';
exit(json_encode($answer));
@@ -67,6 +68,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
// ── Step 1b: Throttle — every registration sends a verification email ────────
rate_limit_guard($pdo1, [
['register_ip', rate_limit_client_ip(), 10, 3600],
['register_email', strtolower(trim((string)($data['email'] ?? ''))), 3, 3600],
]);
try {
// ── Step 2: Sanitise input ────────────────────────────────────────────────
+99 -134
View File
@@ -4,42 +4,31 @@
*
* Called by: login page AJAX "Resend OTP" button on the OTP input screen.
* Input: All data sourced from $_SESSION (written by login_otp.php).
* No new user input is accepted — credentials are re-read from session
* to avoid re-exposing the password in a second HTTP request.
* No new user input is accepted.
*
* This endpoint regenerates a fresh TOTP and resends the OTP email without
* requiring the user to re-enter their username and password. It is only
* reachable after login_otp.php has successfully validated credentials and
* written the login session state.
* reachable while a pending login exists: login_otp.php verified the password
* less than LOGIN_PENDING_SECONDS ago (password_verified_at). The password
* itself is never kept in the session, so it is not re-checked here.
*
* Full flow:
* 1. Reload username, password, and user_id from session.
* 2. Fetch the full user row (need the password hash to regenerate OTP
* and the email address to resend to).
* 3. Re-verify the stored password against the session-stored hash.
* This is a safety re-check — the session could theoretically have been
* tampered with between login_otp.php and this call.
* 4. On password mismatch → clear cookies, return "Incorrect Password".
* 5. On success:
* a. Generate a fresh 6-digit TOTP (new timestamp → new OTP).
* b. Generate a new 6-letter reference number.
* c. Send the OTP email via system SMTP ($SMTP from config.php).
* Note: uses system-level SMTP unconditionally (unlike login_otp.php
* which tries the company SMTP first). The if(true) wrapper is a
* placeholder left from the original — email always sends.
* d. Clear session and repopulate with new OTP state.
* 6. Return { success: 1, message: "Login Complete!" }.
* 1. Require a fresh pending login; otherwise HTTP 401 (code "login_restart").
* 2. Throttle: per client IP, per user, and at most LOGIN_OTP_MAX_RESENDS
* resends per pending login (HTTP 429).
* 3. Fetch the user row (password hash for the OTP, email to send to).
* 4. Generate a fresh 6-digit TOTP (new timestamp → new OTP) and a random
* 6-letter reference number.
* 5. Send the OTP email with the same SMTP choice as login_otp.php: the
* default company's SMTP when configured, otherwise the system $SMTP.
* 6. Write the new OTP state (the wrong-attempt counter restarts).
* 7. Return { success: 1, message: "Login Complete!" }.
*
* Session keys read:
* login_data['username'], login_data['password'], login_user_id
* login_user_id, password_verified_at, otp_resends
*
* Session keys overwritten:
* login_data, otp, otpTime, reference, user_email, login_user_id
* (same keys as login_otp.php — login_confirm.php reads the same structure)
*
* Response JSON:
* On success: { "success": 1, "message": "Login Complete!" }
* On failure: { "message": "Incorrect Password" }
* otp, otpTime, reference, user_email, otp_attempts, otp_resends
*/
require_once '../../../session.php';
@@ -47,120 +36,96 @@ require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/rate_limit.php';
require_once '../login_helpers.php';
// ── Step 1: Reload credentials from session ───────────────────────────────────
// These were stored by login_otp.php so the user doesn't have to retype them.
$data["username"] = $_SESSION["login_data"]['username'];
$data["password"] = $_SESSION["login_data"]['password'];
$user_id = (int)$_SESSION["login_user_id"];
// ── Step 1: Require a pending login ───────────────────────────────────────────
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 ─────────────────────────────────────────────────
// ── Step 2: Throttle resends ──────────────────────────────────────────────────
if ((int)($_SESSION['otp_resends'] ?? 0) >= LOGIN_OTP_MAX_RESENDS) {
rate_limit_reject();
}
rate_limit_guard($pdo1, [
['otp_resend_ip', rate_limit_client_ip(), 10, 900],
['otp_resend_user', (string)$user_id, 5, 900],
]);
// ── Step 3: Fetch user record ─────────────────────────────────────────────────
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
$sth->execute([":user_id" => $user_id]);
$temp = $sth->fetch(PDO::FETCH_ASSOC);
$user_email = $temp["email"];
// ── Step 3–4: Re-verify password ─────────────────────────────────────────────
// Safety check — ensures the session hasn't been tampered with between
// login_otp.php and this resend call.
if (password_verify(trim($data["password"]), $temp["password"])) {
// ── Step 5a: Generate fresh 6-digit TOTP ──────────────────────────────────
// Same HMAC-SHA1 algorithm as login_otp.php and login_confirm.php.
// A new $otpTime is captured so the OTP window resets from this moment.
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
global $otpTime;
$otpTime = time(); // new timestamp — extends the 5-minute validity window
$counter = floor($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);
}
// ── Step 5b: Generate 6-letter reference number ───────────────────────────
// Converts a second TOTP (derived from the first OTP as the key) to a
// base-26 uppercase letter string shown on the OTP input screen.
function numberToLetters($num) {
$result = '';
while ($num > 0) {
$mod = ($num - 1) % 26;
$result = chr(65 + $mod) . $result;
$num = intval(($num - $mod) / 26);
}
return str_pad($result, 6, 'A', STR_PAD_LEFT);
}
$otp = generateOTP($temp["password"]);
$reference_number = numberToLetters(generateOTP($otp));
// ── Step 5c: Send OTP email ───────────────────────────────────────────────
// Uses the system-level $SMTP config from config.php.
// The if(true) wrapper is a no-op placeholder from the original code —
// the email block always executes.
require "../../../assets/utils/module/mailer.php";
if (true) {
$mailer = new mailer(["pdo1" => $pdo1]);
$mailer->send_email([
"company_id" => 0,
"smtp" => $SMTP,
"subject" => "One Time Password (OTP) For reference number " . $reference_number,
"message" => implode("\n", [
"Dear WMS user,",
"",
"You requested a One-Time Password (OTP) to log in to WMS.",
"",
"Please use the OTP below to complete your request:",
"• OTP code: " . $otp,
"• Reference number: " . $reference_number,
"",
"Please note:",
"• This code will expire in 3 minutes. Please complete your action promptly.",
"• Do not share this code with anyone to keep your account secure.",
"• If you did not request this code, please ignore this email.",
]),
"channel_name" => "WMS LOGIN OTP ",
"to" => $user_email,
"key" => $pinkey,
]);
}
// ── Step 5d: Reset session with new OTP state ─────────────────────────────
// Full session is cleared before repopulating to avoid stale state
// from the previous OTP attempt leaking into this one.
if (!$temp) {
$_SESSION = [];
$_SESSION["login_data"] = $data;
$_SESSION["otp"] = $otp;
$_SESSION["otpTime"] = $otpTime; // new timestamp — login_confirm.php uses this
$_SESSION["reference"] = $reference_number;
$_SESSION["user_email"] = $user_email;
$_SESSION["login_user_id"] = $user_id;
// ── Step 6: Respond ───────────────────────────────────────────────────────
$answer["success"] = 1;
$answer["message"] = "Login Complete!";
exit(json_encode($answer));
} else {
// ── Password mismatch — clear cookies and reject ──────────────────────────
$answer["message"] = "Incorrect Password";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
exit(json_encode($answer));
login_fail(401, 'Your sign-in has expired. Please enter your username and password again.', ['code' => 'login_restart']);
}
$user_email = $temp["email"];
// ── Step 4: Generate fresh 6-digit TOTP + random reference ────────────────────
// Same HMAC-SHA1 algorithm as login_otp.php and login_confirm.php.
// A new $otpTime is captured so the OTP window resets from this moment.
$otpTime = time();
$otp = login_generate_otp($temp["password"], $otpTime);
$reference_number = login_random_reference();
// ── Step 5: Send OTP email ────────────────────────────────────────────────────
// Company SMTP of the user's default company when configured, otherwise the
// system-level $SMTP from config.php.
$smtp_config = $SMTP;
$default_company = (int)($temp["default_company"] ?? 0);
if ($default_company > 0) {
$sth = $pdo1->prepare("SELECT * FROM company_smtp WHERE company_id = :cid LIMIT 1");
$sth->execute([":cid" => $default_company]);
$smtp_row = $sth->fetch(PDO::FETCH_ASSOC);
if (!empty($smtp_row)) {
$smtp_config = $smtp_row;
}
}
require "../../../assets/utils/module/mailer.php";
$mailer = new mailer(["pdo1" => $pdo1, "pdo2" => $pdo2]);
$mailer->send_email([
"company_id" => $smtp_config === $SMTP ? 0 : $default_company,
"smtp" => $smtp_config,
"subject" => "One Time Password (OTP) For reference number " . $reference_number,
"message" => implode("\n", [
"Dear WMS user,",
"",
"You requested a One-Time Password (OTP) to log in to WMS.",
"",
"Please use the OTP below to complete your request:",
"• OTP code: " . $otp,
"• Reference number: " . $reference_number,
"",
"Please note:",
"• This code will expire in 3 minutes. Please complete your action promptly.",
"• Do not share this code with anyone to keep your account secure.",
"• If you did not request this code, please ignore this email.",
]),
"channel_name" => "WMS LOGIN OTP ",
"to" => $user_email,
"key" => $pinkey,
]);
// ── Step 6: Write the new OTP state ───────────────────────────────────────────
// The pending-login keys (login_data, login_user_id, password_verified_at) stay
// as they are; only the OTP state is replaced.
$_SESSION["otp"] = $otp;
$_SESSION["otpTime"] = $otpTime; // new timestamp — login_confirm.php uses this
$_SESSION["reference"] = $reference_number;
$_SESSION["user_email"] = $user_email;
$_SESSION["otp_attempts"] = 0;
$_SESSION["otp_resends"] = (int)($_SESSION["otp_resends"] ?? 0) + 1;
// ── Step 7: Respond ───────────────────────────────────────────────────────────
$answer["success"] = 1;
exit(json_encode($answer));
$answer["message"] = "Login Complete!";
exit(json_encode($answer));
+16 -9
View File
@@ -1,8 +1,16 @@
<?php
/**
* request_reset_otp.php — login page "Forgot password?" step 1.
*
* Unauthenticated. Answers the same way whether or not the username/email
* matches an account (see PasswordResetManager::handleRequestOtpPublic), and is
* throttled per client IP and per identifier because every hit can send email.
*/
require_once '../../../session.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/rate_limit.php';
// Resolve user by username or email
$identifier = strtolower(trim($data['identifier'] ?? ''));
@@ -13,22 +21,21 @@ if (!$identifier) {
exit(json_encode($answer));
}
rate_limit_guard($pdo1, [
['reset_req_ip', rate_limit_client_ip(), 10, 900],
['reset_req_id', $identifier, 3, 900],
]);
$sth = $pdo1->prepare(
"SELECT user_id, default_company FROM user WHERE username = :i OR email = :i LIMIT 1"
);
$sth->execute([':i' => $identifier]);
$user = $sth->fetch(PDO::FETCH_ASSOC);
if (!$user) {
http_response_code(404);
$answer['message'] = 'No account found with that username or email.';
exit(json_encode($answer));
}
$user_id = (int)$user['user_id'];
$company_id = (int)($user['default_company'] ?? 0);
$user_id = $user ? (int)$user['user_id'] : null;
$company_id = $user ? (int)($user['default_company'] ?? 0) : 0;
require_once '../../../assets/utils/classes/PasswordResetManager.php';
$manager = new PasswordResetManager($pdo1, $pdo2, $include_url, $SMTP, $pinkey);
$manager->handleRequestOtp($user_id, $company_id);
$manager->handleRequestOtpPublic($user_id, $company_id);
+14 -1
View File
@@ -1,10 +1,23 @@
<?php
/**
* reset_password_otp.php — login page "Forgot password?" step 2.
*
* Unauthenticated. Needs the reset state written by request_reset_otp.php in
* this session. For a username that matched no account that state is a decoy
* (reset_user_id 0) which always answers "Incorrect OTP", so this step does not
* reveal whether the account exists either.
*/
require_once '../../../session.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/rate_limit.php';
if (empty($_SESSION['reset_user_id'])) {
rate_limit_guard($pdo1, [
['reset_confirm_ip', rate_limit_client_ip(), 30, 900],
]);
if (!isset($_SESSION['reset_user_id'])) {
http_response_code(400);
$answer['message'] = 'No active reset request. Please request a new OTP.';
exit(json_encode($answer));