$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. * 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. * 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 * 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": "" } * Special: { "message": "wait" } — device pending whitelist approval * { "message": "block" } — device is blacklisted * { "expire": "expire" } — licence has expired */ require '../../../session.php'; require '../../../config.php'; require '../../../preset.php'; define('UNAUTHENTICATED_ROUTE', true); require '../../../assets/utils/db_auth.php'; // ── 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"]))); $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. 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)); } 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 (password_verify(trim($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) { $answer["message"] = "" . $user_email . " is not eligible email, please contact your administrator to change your email."; exit(json_encode($answer)); } // ── 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 $include_url . '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, ]); 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") { $answer["message"] = "Your account has been deactivated. Please contact your administrator."; exit(json_encode($answer)); } // ── 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(); $answer["message"] = "wait"; setcookie("u", "", time() - 1, "/"); setcookie("h1", "", time() - 1, "/"); setcookie("h2", "", time() - 1, "/"); echo json_encode($answer); } else { $coo = $sth->fetch(PDO::FETCH_ASSOC); if ($coo["status"] == "0") { // Device explicitly blocked by admin session_destroy(); $answer["message"] = "block"; setcookie("u", "", time() - 1, "/"); setcookie("h1", "", time() - 1, "/"); setcookie("h2", "", time() - 1, "/"); echo json_encode($answer); $deviceDecision = ['type' => 'BLOCKED', 'status' => 0]; } else if ($coo["status"] == "1") { // Device registered but not yet approved — notify admin session_destroy(); $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(); $answer["expire"] = "expire"; 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). 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); } // ── 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)); // ── 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" => "Your OTP is " . $otp . " for reference number " . $reference_number, // "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["login_data"] = $data; // preserved for request_new_otp.php resend flow $_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 // ── 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. 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)); } $answer["success"] = 1; exit(json_encode($answer));