Files
wms-app/app/login/api/engine/login_otp.php
T

375 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 '../../../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"] = "<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));