375 lines
19 KiB
PHP
375 lines
19 KiB
PHP
<?php
|
||
/**
|
||
* login_otp.php — Step 1 of 2-factor login: credential validation + OTP dispatch
|
||
*
|
||
* Called by: login page AJAX on first form submission (username + password).
|
||
* Input: $data['username'], $data['password'], $data['cookie'] (from preset.php)
|
||
*
|
||
* This is the first of two login steps. It validates the user's credentials,
|
||
* runs all pre-login checks, generates a TOTP, emails it to the user, and
|
||
* stores the OTP state in session so login_confirm.php can verify it.
|
||
*
|
||
* Full flow:
|
||
* 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".
|
||
* 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'):
|
||
* - Generate a fresh 30-day verification token.
|
||
* - Resend verification email (silently ignore mailer errors).
|
||
* - Return a message instructing the user to check their inbox.
|
||
* c. Deactivated account (status = 'not activated') → block with message.
|
||
* d. Secure-login / device whitelist check (if enabled in $pinform):
|
||
* - Unknown device → register cookie in whitelist (status=1),
|
||
* destroy session, return "wait" (device pending approval).
|
||
* - Blocked device (status=0) → destroy session, return "block".
|
||
* - Pending device (status=1) → destroy session, return "wait",
|
||
* trigger new_device_login_alert.php notification.
|
||
* - Approved device (status=2) → proceed.
|
||
* - 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.
|
||
* 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": "<reason>" }
|
||
* 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';
|
||
|
||
// ── 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"] = "<b>" . $user_email . "</b> 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 '../../../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:",
|
||
"",
|
||
"<a href='{$verify_url}' style='display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;'>Verify Email Address</a>",
|
||
"",
|
||
"Or copy and paste this link into your browser:",
|
||
"<a href='{$verify_url}'>{$verify_url}</a>",
|
||
"",
|
||
"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)); |