$expire + 1 day → return "expire". * 6. Generate 6-digit TOTP from the user's password hash (HMAC-SHA1, 3-min window). * 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 (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 — { 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 * user_email — masked in UI; full value stored for display * login_user_id — resolved user_id (used by login_confirm.php) * no_smtp — true if no company SMTP exists (OTP step is skipped) * * Response JSON: * On success: { "success": 1, "skip_otp": bool, "message": "Login Complete!" } * 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 */ require_once '../../../session.php'; require_once '../../../config.php'; 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($username)); $user_id = $sth->fetchColumn(); // ── 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 ── // 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 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") ->execute([':id' => $user_id]); $temp['login_attempts'] = 0; } } // ── Step 3–4: Verify password — exit with error on mismatch ────────────────── if ($temp && password_verify(trim((string)($data["password"] ?? '')), $temp["password"])) { // ── Reset lockout on successful password verification ───────────────────── if ($user_id) { $pdo1->prepare("UPDATE user SET login_attempts = 0, locked_until = NULL WHERE user_id = :id") ->execute([':id' => $user_id]); } // ── Step 5a: Fetch full user record ────────────────────────────────────── // 'support' user gets a hardcoded email so it can always log in even without // a registered email address in the DB. if (strtolower($data["username"]) == "support") { $s = $pdo1->query("select *, 'info@trcloud.co' as email from user where username='support' limit 1;"); $r = $s->fetch(PDO::FETCH_ASSOC); } else { $s = $pdo1->prepare("select * from user where (username=? or email=?) and user_id = ? limit 1;"); $s->execute(array($username, $username, $user_id)); $r = $s->fetch(PDO::FETCH_ASSOC); } $user_email = $r["email"]; // ── Step 5b: Email format guard ─────────────────────────────────────────── // 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) { // 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') ───────────────────── // Generate a fresh verification token and resend the email. // Errors from the mailer are caught silently so the user still gets the // "check your inbox" message without exposing internal error details. if ($r["status"] === "pending") { $token = bin2hex(random_bytes(32)); $expires_at = date('Y-m-d H:i:s', strtotime('+30 days')); $sth = $pdo1->prepare("UPDATE user SET verify_token = :token, verify_expires_at = :expires WHERE user_id = :id"); $sth->execute([':token' => $token, ':expires' => $expires_at, ':id' => $r['user_id']]); // Build absolute verify URL from current server context $base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . rtrim($server_url, '/'); $verify_url = $base_url . '/login/verify.php?token=' . $token; require_once '../../../assets/utils/module/mailer.php'; $mailer = new mailer(['pdo1' => $pdo1]); $mail_sent = $mailer->send_email([ 'company_id' => 0, 'smtp' => $SMTP, 'silent' => true, 'to' => $r['email'], 'subject' => 'Verify your email — WMS', 'message' => implode("\n", [ "Hi {$r['name']},", "", "You attempted to login but your email is not yet verified.", "Please verify your email address by clicking the button below:", "", "Verify Email Address", "", "Or copy and paste this link into your browser:", "{$verify_url}", "", "This link will expire in 30 days.", ]), 'channel_name' => 'WMS', '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 { $answer["message"] = "Your email is not verified. Verification email could not be sent — please contact your administrator."; $answer["verify_url"] = $verify_url; } exit(json_encode($answer)); } // ── Step 5d: Deactivated account ───────────────────────────────────────── if ($r["status"] === "not activated") { login_fail(403, "Your account has been deactivated. Please contact your administrator."); } // ── Step 5e: Secure-login device whitelist check ────────────────────────── // Only enforced when secure_login is "on" in $pinform and the licence // is not "lord". The user's browser sends a device cookie ($data["cookie"]). // - Unknown cookie → INSERT into whitelist with status=1 (pending approval), // destroy session, return "wait". // - status=0 (blocked) → destroy session, return "block". // - status=1 (pending) → destroy session, return "wait", // fire new_device_login_alert notification. // - status=2 (approved) → fall through and continue login. if (isset($pinform["secure_login"]) && $pinform["secure_login"] == "on" && $_SESSION["license"] != "lord") { $sth = $pdo1->prepare("select * from whitelist where cookie = :cookie"); $sth->execute(array(":cookie" => $data["cookie"])); if ($sth->rowCount() == 0) { // Register unknown device as pending approval $s = $pdo1->prepare("INSERT INTO `whitelist` (`cookie`, `status`, `ip`) VALUES (:cookie, '1', :ip) on duplicate key update ip = values(ip);"); $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, "/"); // Stop here: the session is gone, nothing below may run. exit(json_encode($answer)); } else { $coo = $sth->fetch(PDO::FETCH_ASSOC); if ($coo["status"] == "0") { // Device explicitly blocked by admin session_destroy(); http_response_code(403); $answer["message"] = "block"; setcookie("u", "", time() - 1, "/"); setcookie("h1", "", time() - 1, "/"); setcookie("h2", "", time() - 1, "/"); 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, "/"); setcookie("h2", "", time() - 1, "/"); echo json_encode($answer); $deviceDecision = ['type' => 'WAIT_APPROVAL', 'status' => 1]; include __DIR__ . "/api/engine-notification/new_device_login_alert.php"; exit; } else if ($coo["status"] == "2") { // Device approved — continue to OTP step } } } // ── End secure-login device whitelist check ─────────────────────────────── // ── Step 5f: Licence expiry check ──────────────────────────────────────── // $expire is loaded from db_auth.php via session/preset bootstrap. // If the licence expired more than 1 day ago, reject the login. if (strtotime("now") > strtotime($expire . " + 1 day")) { session_destroy(); http_response_code(403); $answer["expire"] = "expire"; $answer["message"] = "Your licence has expired. Please contact your administrator."; exit(json_encode($answer)); } // ── Step 5g: OTP policy, then role check — staff/viewer skip OTP entirely ─ // OTP_REQUIRED=false in config.php turns the email OTP off for everyone and // logs the sign-in as a bypass (see assets/utils/otp_policy.php). // Otherwise owners always require 2FA. Invited users (license='user') require 2FA only // if their role in this company is admin or owner; staff/viewer go straight in. $requires_otp = otp_required(); if (!$requires_otp) { otp_log_bypass($user_id, 'login_otp'); } elseif (($r['license'] ?? 'owner') !== 'owner') { $sth_role = $pdo1->prepare( "SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1" ); $sth_role->execute([':cid' => (int)($r['default_company'] ?? 0), ':uid' => (int)$r['user_id']]); $role_for_otp = ($sth_role->fetch(PDO::FETCH_ASSOC))['role'] ?? 'viewer'; $requires_otp = in_array($role_for_otp, ['admin', 'owner'], true); } if (!$requires_otp) { $_SESSION = []; 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!'; exit(json_encode($answer)); } // ── Step 6: Generate 6-digit TOTP ──────────────────────────────────────── // 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). $otpTime = time(); $otp = login_generate_otp($temp["password"], $otpTime); // ── Step 7: Generate 6-letter reference number ─────────────────────────── // 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. // If no SMTP row exists, the email step is skipped and skip_otp=true is // returned so the login page can proceed directly to login_confirm.php // without waiting for an OTP the user will never receive. $smtp_config = null; $default_company = (int)($r["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; } } if (!empty($smtp_config)) { require "../../../assets/utils/module/mailer.php"; $mailer = new mailer(["pdo1" => $pdo1, "pdo2" => $pdo2]); $mailer->send_email([ "company_id" => $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 9: Reset session and write OTP state ───────────────────────────── // 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"] = ['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 $_SESSION["user_email"] = $user_email; // shown masked on OTP screen $_SESSION["login_user_id"] = $user_id; // used by login_confirm.php to build the login session $_SESSION["no_smtp"] = empty($smtp_config); // true = skip OTP step on login page if (empty($smtp_config)) { $_SESSION['skip_otp'] = true; } // ── Step 10: Respond ────────────────────────────────────────────────────── $answer["success"] = 1; $answer["skip_otp"] = empty($smtp_config); // login page skips OTP screen when true $answer["message"] = "Login Complete!"; exit(json_encode($answer)); } else { // ── 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]); } else { $pdo1->prepare("UPDATE user SET login_attempts = :a WHERE user_id = :id") ->execute([':a' => $attempts, ':id' => $user_id]); } } setcookie("u", "", time() - 1, "/"); setcookie("h1", "", time() - 1, "/"); setcookie("h2", "", time() - 1, "/"); login_fail(401, LOGIN_GENERIC_FAILURE); } $answer["success"] = 1; exit(json_encode($answer));