From 73c680e84400c87e5508fc3a1265fd45c807ced8 Mon Sep 17 00:00:00 2001 From: Thanakorn Date: Thu, 24 Sep 2026 14:53:40 +0700 Subject: [PATCH] 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 --- .../utils/classes/PasswordResetManager.php | 110 +++++++-- app/assets/utils/rate_limit.php | 99 ++++++++ app/login/api/engine/accept_invite.php | 2 +- app/login/api/engine/invited_onboarding.php | 2 +- app/login/api/engine/login_confirm.php | 65 ++--- app/login/api/engine/login_otp.php | 117 +++++---- app/login/api/engine/onboarding.php | 12 +- app/login/api/engine/register.php | 9 +- app/login/api/engine/request_new_otp.php | 233 ++++++++---------- app/login/api/engine/request_reset_otp.php | 25 +- app/login/api/engine/reset_password_otp.php | 15 +- app/login/api/login_helpers.php | 68 +++++ app/login/forgot_password.php | 14 +- app/login/index.php | 28 ++- app/setting/api/engine/request_reset_otp.php | 12 +- app/setting/api/engine/reset_password_otp.php | 9 +- 16 files changed, 538 insertions(+), 282 deletions(-) create mode 100644 app/assets/utils/rate_limit.php create mode 100644 app/login/api/login_helpers.php diff --git a/app/assets/utils/classes/PasswordResetManager.php b/app/assets/utils/classes/PasswordResetManager.php index 7da4e35..0d40966 100644 --- a/app/assets/utils/classes/PasswordResetManager.php +++ b/app/assets/utils/classes/PasswordResetManager.php @@ -14,20 +14,25 @@ * * The OTP is a 6-digit TOTP derived from the user's current password hash via HMAC-SHA1, * scoped to a 3-minute time step. It cannot be replayed after the window expires. - * A human-readable reference number (6 uppercase letters) is also generated and emailed - * so the user can confirm they received the correct OTP request. + * A random reference number (6 uppercase letters) is also generated and emailed so the + * user can confirm they received the correct OTP request. It is not derived from the + * OTP: a derived reference let anyone who saw it recover the OTP offline. * * HTTP handler methods for thin AJAX endpoint wrappers: - * handleRequestOtp($user_id, $company_id) + * handleRequestOtp($user_id, $company_id) — signed-in profile page + * handleRequestOtpPublic($user_id, $company_id) — login page; same answer whether + * or not the account exists * handleConfirmReset($user_id, $data) * * Session keys used (prefixed with 'reset_' to avoid collision with login OTP): - * reset_otp, reset_otp_time, reset_reference, reset_user_id + * reset_otp, reset_otp_time, reset_reference, reset_user_id, reset_attempts * * Security: * - OTP is HMAC-derived from the current password hash — it changes when the password changes. * - OTP is valid for OTP_EXPIRY_MINUTES (5) only; older OTPs are rejected with clearSession(). * - reset_user_id in session is verified against $user_id to prevent cross-user OTP reuse. + * - At most OTP_MAX_ATTEMPTS wrong entries per issued OTP, then it is discarded. + * - OTPs are compared with hash_equals(). * - Session is fully destroyed on successful reset, forcing re-authentication. * - All DB queries use PDO prepared statements with bound parameters. * - AJAX handler methods output JSON via json_encode (XSS-safe). @@ -43,6 +48,12 @@ class PasswordResetManager { /** OTP validity window in minutes — matches the login OTP window. */ const OTP_EXPIRY_MINUTES = 5; + /** Wrong OTP entries allowed per issued OTP before it is discarded. */ + const OTP_MAX_ATTEMPTS = 5; + + /** Answer shown on the login page whether or not the account exists. */ + const PUBLIC_REQUEST_MESSAGE = "If an account matches, we've sent an OTP to its email."; + /** * @param PDO $pdo1 PDO connection to the wms database (user table). * @param PDO $pdo2 PDO connection to the company database (smtp_setting table). @@ -94,10 +105,10 @@ class PasswordResetManager { throw new \RuntimeException('No email address found for this account.'); } - // Generate 6-digit TOTP and a human-readable 6-letter reference number + // Generate 6-digit TOTP and a random 6-letter reference number $otp_time = time(); $otp = $this->generateOTP($user['password'], $otp_time); - $reference_number = $this->numberToLetters((int) $this->generateOTP($otp, $otp_time)); + $reference_number = $this->randomReference(); // Send via the mailer module (uses company SMTP or falls back to system default) require_once $this->include_url . '/assets/utils/module/mailer.php'; @@ -124,6 +135,7 @@ class PasswordResetManager { $_SESSION['reset_otp_time'] = $otp_time; $_SESSION['reset_reference'] = $reference_number; $_SESSION['reset_user_id'] = $user_id; + $_SESSION['reset_attempts'] = 0; return [ 'masked_email' => $this->maskEmail($user['email']), @@ -131,6 +143,24 @@ class PasswordResetManager { ]; } + /** + * Start a reset that can never succeed, for a login-page request whose + * username/email matches no account. The session then looks exactly like a + * real request (random unguessable OTP, reset_user_id 0), so the confirm step + * answers "Incorrect OTP" instead of revealing that the account is missing. + * + * @return string Random 6-letter reference, same shape as a real one. + */ + public function startDecoy(): string { + $reference = $this->randomReference(); + $_SESSION['reset_otp'] = bin2hex(random_bytes(16)); + $_SESSION['reset_otp_time'] = time(); + $_SESSION['reset_reference'] = $reference; + $_SESSION['reset_user_id'] = 0; + $_SESSION['reset_attempts'] = 0; + return $reference; + } + /** * Verify the OTP and force-set a new password via PasswordManager. * @@ -170,8 +200,14 @@ class PasswordResetManager { throw new \InvalidArgumentException('OTP has expired. Please request a new one.'); } - // Verify OTP value - if (trim($otp_input) !== $_SESSION['reset_otp']) { + // Verify OTP value — at most OTP_MAX_ATTEMPTS wrong entries per issued OTP, + // so the 6-digit code cannot be brute-forced inside its 5-minute window. + if (!hash_equals((string)$_SESSION['reset_otp'], trim($otp_input)) || $user_id <= 0) { + $_SESSION['reset_attempts'] = (int)($_SESSION['reset_attempts'] ?? 0) + 1; + if ($_SESSION['reset_attempts'] >= self::OTP_MAX_ATTEMPTS) { + $this->clearSession(); + throw new \InvalidArgumentException('Too many incorrect OTP attempts. Please request a new OTP.'); + } throw new \InvalidArgumentException('Incorrect OTP. Please try again.'); } @@ -234,6 +270,39 @@ class PasswordResetManager { exit; } + /** + * Handle the login-page request-OTP call. The answer is the same whether or + * not the username/email matches an account (no account enumeration): no + * masked email, a generic message and a reference number. Mail failures are + * logged, not reported, for the same reason. + * + * On success (always): { success: 1, message: PUBLIC_REQUEST_MESSAGE, reference: "ABCDEF" } + * + * @param int|null $user_id Resolved account, or null when nothing matched. + * @param int $company_id Company SMTP scope (0 = use system default). + */ + public function handleRequestOtpPublic(?int $user_id, int $company_id = 0): void { + + $reference = null; + if ($user_id) { + try { + $reference = $this->requestOtp($user_id, $company_id)['reference']; + } catch (\Exception $e) { + error_log('[PasswordResetManager::handleRequestOtpPublic] ' . $e->getMessage()); + } + } + if ($reference === null) { + $reference = $this->startDecoy(); + } + + echo json_encode([ + 'success' => 1, + 'message' => self::PUBLIC_REQUEST_MESSAGE, + 'reference' => $reference, + ]); + exit; + } + /** * Handle an AJAX confirm-reset call and echo a JSON response. * @@ -312,23 +381,17 @@ class PasswordResetManager { } /** - * Convert a positive integer into a base-26 uppercase letter string. + * Random 6-letter uppercase reference code (e.g. "BCDFHJ") for the reset email + * and the confirmation screen. Carries no information about the OTP. * - * Used to turn the numeric reference OTP into a human-friendly 6-letter - * reference code (e.g. 123456 → "BCDFHJ") for inclusion in the reset email. - * The result is left-padded with 'A' to always return a 6-character string. - * - * @param int $num Positive integer to convert. - * @return string 6-character uppercase string (e.g. "AAAABC"). + * @return string 6-character uppercase string. */ - private function numberToLetters(int $num): string { + private function randomReference(): string { $result = ''; - while ($num > 0) { - $mod = ($num - 1) % 26; - $result = chr(65 + $mod) . $result; - $num = intval(($num - $mod) / 26); + for ($i = 0; $i < 6; $i++) { + $result .= chr(65 + random_int(0, 25)); } - return str_pad($result, 6, 'A', STR_PAD_LEFT); + return $result; } /** @@ -356,14 +419,15 @@ class PasswordResetManager { * * Called on OTP expiry (to invalidate the request) and on successful * reset (before session_destroy). Does not destroy the full session — - * only the 4 reset-specific keys are unset. + * only the reset-specific keys are unset. */ private function clearSession(): void { unset( $_SESSION['reset_otp'], $_SESSION['reset_otp_time'], $_SESSION['reset_reference'], - $_SESSION['reset_user_id'] + $_SESSION['reset_user_id'], + $_SESSION['reset_attempts'] ); } } \ No newline at end of file diff --git a/app/assets/utils/rate_limit.php b/app/assets/utils/rate_limit.php new file mode 100644 index 0000000..5310f30 --- /dev/null +++ b/app/assets/utils/rate_limit.php @@ -0,0 +1,99 @@ +prepare( + "INSERT INTO auth_throttle (bucket, key_hash, window_start, hits) + VALUES (:b, :k, NOW(), 1) + ON DUPLICATE KEY UPDATE + hits = IF(window_start < NOW() - INTERVAL :w1 SECOND, 1, hits + 1), + window_start = IF(window_start < NOW() - INTERVAL :w2 SECOND, NOW(), window_start)" + )->execute([':b' => $bucket, ':k' => $key_hash, ':w1' => $window_seconds, ':w2' => $window_seconds]); + + $sth = $pdo->prepare("SELECT hits FROM auth_throttle WHERE bucket = :b AND key_hash = :k"); + $sth->execute([':b' => $bucket, ':k' => $key_hash]); + return (int)$sth->fetchColumn() > $max; + } catch (Throwable $e) { + error_log('[rate_limit] throttle check skipped (' . $bucket . '): ' . $e->getMessage()); + return false; + } + } +} + +if (!function_exists('rate_limit_guard')) { + /** + * Count every check and stop the request with HTTP 429 if any is over its + * limit. Each check is [bucket, key, max, window_seconds]. + */ + function rate_limit_guard(PDO $pdo, array $checks): void { + $limited = false; + foreach ($checks as [$bucket, $key, $max, $window]) { + if (rate_limit_hit($pdo, $bucket, (string)$key, (int)$max, (int)$window)) { + $limited = true; + } + } + if ($limited) { + rate_limit_reject(); + } + } +} + +if (!function_exists('rate_limit_reject')) { + /** Answer 429 with the same generic message everywhere and stop. */ + function rate_limit_reject(): void { + http_response_code(429); + header('Retry-After: 300'); + exit(json_encode([ + 'success' => 0, + 'message' => 'Too many requests. Please wait a few minutes and try again.', + 'code' => 'rate_limited', + ])); + } +} diff --git a/app/login/api/engine/accept_invite.php b/app/login/api/engine/accept_invite.php index 4a63c96..0a8e5d8 100644 --- a/app/login/api/engine/accept_invite.php +++ b/app/login/api/engine/accept_invite.php @@ -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)); diff --git a/app/login/api/engine/invited_onboarding.php b/app/login/api/engine/invited_onboarding.php index d0889eb..48854da 100644 --- a/app/login/api/engine/invited_onboarding.php +++ b/app/login/api/engine/invited_onboarding.php @@ -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)); diff --git a/app/login/api/engine/login_confirm.php b/app/login/api/engine/login_confirm.php index 0166c76..e64e015 100644 --- a/app/login/api/engine/login_confirm.php +++ b/app/login/api/engine/login_confirm.php @@ -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 diff --git a/app/login/api/engine/login_otp.php b/app/login/api/engine/login_otp.php index dbee542..b4b130f 100644 --- a/app/login/api/engine/login_otp.php +++ b/app/login/api/engine/login_otp.php @@ -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": "" } + * On failure: { "message": "" } 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"] = "" . $user_email . " 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, "" . htmlspecialchars((string)$user_email, ENT_QUOTES, 'UTF-8') . " 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; diff --git a/app/login/api/engine/onboarding.php b/app/login/api/engine/onboarding.php index 433f2a6..c4d9cf9 100644 --- a/app/login/api/engine/onboarding.php +++ b/app/login/api/engine/onboarding.php @@ -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 = [ diff --git a/app/login/api/engine/register.php b/app/login/api/engine/register.php index b49fab2..b1b5bbb 100644 --- a/app/login/api/engine/register.php +++ b/app/login/api/engine/register.php @@ -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 ──────────────────────────────────────────────── diff --git a/app/login/api/engine/request_new_otp.php b/app/login/api/engine/request_new_otp.php index 460be3a..96912a8 100644 --- a/app/login/api/engine/request_new_otp.php +++ b/app/login/api/engine/request_new_otp.php @@ -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)); \ No newline at end of file +$answer["message"] = "Login Complete!"; +exit(json_encode($answer)); diff --git a/app/login/api/engine/request_reset_otp.php b/app/login/api/engine/request_reset_otp.php index 22dcade..cd9ecc8 100644 --- a/app/login/api/engine/request_reset_otp.php +++ b/app/login/api/engine/request_reset_otp.php @@ -1,8 +1,16 @@ 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); diff --git a/app/login/api/engine/reset_password_otp.php b/app/login/api/engine/reset_password_otp.php index 16cd3ac..26fc60f 100644 --- a/app/login/api/engine/reset_password_otp.php +++ b/app/login/api/engine/reset_password_otp.php @@ -1,10 +1,23 @@ 0, 'message' => $message], $extra))); +} diff --git a/app/login/forgot_password.php b/app/login/forgot_password.php index 507532a..2428a89 100644 --- a/app/login/forgot_password.php +++ b/app/login/forgot_password.php @@ -11,12 +11,12 @@ $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } - require '../include_header.php'; + require __DIR__ . '/include_login_header.php'; ?> - +
@@ -50,8 +50,8 @@
- OTP sent to - — reference + + Reference
@@ -163,8 +163,10 @@ autoPrepare: false, data: { json: JSON.stringify({ action: 'read', identifier: identifier }) }, onSuccess: function (r) { - $('#masked_email').text(r.masked_email); - $('#ref_code').text(r.reference); + // The server answers the same way whether or not the account exists, + // so there is no masked email to show — only its generic message. + $('#request_message').text(r.message || ''); + $('#ref_code').text(r.reference || ''); $('#step_request').addClass('d-none'); $('#step_reset').removeClass('d-none'); $('#subtitle').text('Enter the OTP from your email and choose a new password.'); diff --git a/app/login/index.php b/app/login/index.php index c79b10b..e898c0a 100644 --- a/app/login/index.php +++ b/app/login/index.php @@ -2,7 +2,7 @@ require '../session.php'; require '../config.php'; require_once '../assets/utils/otp_policy.php'; - require '../include_header.php'; + require __DIR__ . '/include_login_header.php'; // successful login — redirect based on app_access if(!empty($_SESSION["login_status"])){ $redirect = ($_SESSION['login_app_access'] ?? 'wms') === 'accounting' @@ -33,13 +33,9 @@
- - -
- - Email OTP is off — sign-in is password only. -
- +
@@ -144,6 +140,16 @@ + // The server ended the pending sign-in (too many wrong OTPs, or the verified + // password is too old): show why, then return to the username/password step. + function restart_login_on(xhr) { + if (xhr?.responseJSON?.code !== 'login_restart') return; + bootbox.hideAll(); + bootbox.alert(escape_html(xhr.responseJSON.message || 'Please sign in again.'), function() { + window.location.href = "login/index.php"; + }); + } + // reqquest new otp function function request_new_otp() { @@ -156,7 +162,8 @@ window.location.href = "index.php"; - } + }, + onError: restart_login_on }); } @@ -194,7 +201,8 @@ window.location.href = "index.php"; - } + }, + onError: restart_login_on }); } diff --git a/app/setting/api/engine/request_reset_otp.php b/app/setting/api/engine/request_reset_otp.php index fc9f655..6e8f708 100644 --- a/app/setting/api/engine/request_reset_otp.php +++ b/app/setting/api/engine/request_reset_otp.php @@ -1,8 +1,16 @@ handleRequestOtp($user_id, $company_id); \ No newline at end of file + $prm->handleRequestOtp($user_id, $company_id); diff --git a/app/setting/api/engine/reset_password_otp.php b/app/setting/api/engine/reset_password_otp.php index 3e9c84f..96e56c3 100644 --- a/app/setting/api/engine/reset_password_otp.php +++ b/app/setting/api/engine/reset_password_otp.php @@ -1,8 +1,13 @@ handleConfirmReset($user_id, $data); \ No newline at end of file + $prm->handleConfirmReset($user_id, $data);