Files
wms-app/app/login/api/engine/login_otp.php
T
Thanakorn 73c680e844 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
2026-09-24 14:53:40 +07:00

416 lines
21 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 (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'):
* - 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 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": "<reason>" } 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, "<b>" . htmlspecialchars((string)$user_email, ENT_QUOTES, 'UTF-8') . "</b> 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:",
"",
"<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,
]);
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));