Apply the configured timezone to PHP and both DB connections, wrap unwrapped ajax payloads so delete buttons reach their engines, normalise and validate invoice due dates, reject stock quantities below the stored 4dp scale, and list stock movements across all warehouses.
237 lines
12 KiB
PHP
237 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* login_confirm.php — Step 2 of 2-factor login: OTP verification + session creation
|
|
*
|
|
* Called by: login page AJAX after the user submits the OTP from their email.
|
|
* Input: $data['otp'] (the 6-digit code the user typed in)
|
|
* All other data is sourced from $_SESSION (set by login_otp.php).
|
|
*
|
|
* This is the second and final step of the login flow. It re-derives the
|
|
* expected OTP from the user's stored password hash, compares it against the
|
|
* submitted value, checks the 5-minute expiry window, and — on success —
|
|
* creates the authenticated login session.
|
|
*
|
|
* Full flow:
|
|
* 1. Load credentials and user_id from session (written by login_otp.php).
|
|
* 2. Fetch the user's full row by user_id to get the current password hash.
|
|
* 3. Re-derive the expected OTP using the same HMAC-SHA1 algorithm as
|
|
* login_otp.php (same secret key = password hash, same time_step = 180s).
|
|
* Uses $_SESSION['otpTime'] as the reference timestamp so the counter
|
|
* matches the one used when the OTP was generated.
|
|
* 4. Check both conditions that must be true for the OTP to be valid:
|
|
* a. The submitted OTP matches the re-derived expected value.
|
|
* b. The elapsed time since otpTime is ≤ 5 minutes.
|
|
* Fail either → return "Wrong OTP! Please try again."
|
|
* 5. On success:
|
|
* a. Concurrent-session check — if the account already has a session_token
|
|
* set and session_last_seen is within SESSION_ACTIVE_GRACE_SECONDS
|
|
* (see below), the login is blocked with "already signed in on another
|
|
* device." A stale or NULL token allows the login (previous session
|
|
* expired naturally, or the user used back.php to log out explicitly).
|
|
* b. session_regenerate_id(true) — prevents session fixation attack by
|
|
* issuing a new session ID and deleting the old one.
|
|
* c. Generate a fresh CSRF token and store in session.
|
|
* d. Write the authenticated login session keys:
|
|
* login_status=1, login_username, login_name, login_surname,
|
|
* login_company_id (from user's default_company).
|
|
* 6. Return { success: 1, message: "Login Complete!" }.
|
|
*
|
|
* Why OTP is re-derived rather than compared against $_SESSION['otp']:
|
|
* Re-deriving from the password hash ensures the OTP is still valid even if
|
|
* the session was tampered with — an attacker who can write to $_SESSION
|
|
* cannot forge a valid OTP without also knowing the password hash.
|
|
*
|
|
* Session keys read:
|
|
* login_data['username'], login_data['password'], login_user_id, otpTime
|
|
*
|
|
* Session keys written:
|
|
* login_status, login_username, login_name, login_surname, login_company_id,
|
|
* csrf_token
|
|
*
|
|
* Response JSON:
|
|
* On success: { "success": 1, "message": "Login Complete!" }
|
|
* On failure: { "message": "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)" }
|
|
*/
|
|
|
|
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';
|
|
|
|
// ── Step 1: Load session state written by login_otp.php ───────────────────────
|
|
$data["username"] = $_SESSION["login_data"]['username'];
|
|
$data["password"] = $_SESSION["login_data"]['password'];
|
|
$user_id = $_SESSION["login_user_id"];
|
|
|
|
// ── Step 2: Fetch user record — need password hash to re-derive the OTP ───────
|
|
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
|
|
$sth->execute([":user_id" => $user_id]);
|
|
$temp = $sth->fetch(PDO::FETCH_ASSOC);
|
|
|
|
// ── Step 3: Re-derive expected OTP ────────────────────────────────────────────
|
|
// Uses $_SESSION['otpTime'] (set when the OTP was generated) as the TOTP
|
|
// counter base. This is the same algorithm used in login_otp.php and
|
|
// request_new_otp.php — any change to one must be reflected in all three.
|
|
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
|
|
$counter = floor($_SESSION["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);
|
|
}
|
|
|
|
$otp = generateOTP($temp["password"]);
|
|
|
|
// ── Step 3b: Calculate elapsed time since OTP was issued ──────────────────────
|
|
// otpTime is the Unix timestamp stored by login_otp.php when the OTP was sent.
|
|
// The diff is computed in minutes for the 5-minute validity window check.
|
|
$otp_time = isset($_SESSION['otpTime']) ? (int)$_SESSION['otpTime'] : 0;
|
|
$now = time();
|
|
$otp_diff_seconds = max(0, $now - $otp_time);
|
|
$otp_diff_minutes = $otp_diff_seconds / 60.0;
|
|
|
|
// Store for debug convenience — visible in $_SESSION on the session inspect page
|
|
$_SESSION["now"] = $now;
|
|
$_SESSION["diff"] = $otp_diff_minutes;
|
|
|
|
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
|
|
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
|
|
// so they never receive or enter an OTP. Admin/owner always go through this check,
|
|
// unless OTP_REQUIRED=false in config.php: that also covers a user who was already
|
|
// on the OTP screen when the switch was turned off.
|
|
if (empty($_SESSION['skip_otp'])) {
|
|
if (!otp_required()) {
|
|
if (!empty($user_id)) {
|
|
otp_log_bypass($user_id, 'login_confirm');
|
|
}
|
|
} elseif ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
|
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
|
|
exit(json_encode($answer));
|
|
}
|
|
}
|
|
|
|
// ── Step 4b: Concurrent session check ────────────────────────────────────────
|
|
// Block the login if this account already has an active session.
|
|
// "Active" = session_token is set AND session_last_seen is within the last
|
|
// SESSION_ACTIVE_GRACE_SECONDS. db_auth.php refreshes session_last_seen on every
|
|
// authenticated request (throttled to once per 60s), so a session that is truly
|
|
// still in use on another device keeps re-touching this timestamp well within
|
|
// the grace window below. A session that has actually ended — browser/tab closed,
|
|
// cookie lost, PHP session GC'd — stops refreshing it and goes stale quickly.
|
|
//
|
|
// This window must stay well above the 60s throttle in db_auth.php (otherwise a
|
|
// live second session could go stale between its own refreshes and let a login
|
|
// through) but short enough that a real re-login isn't blocked for long after the
|
|
// previous session actually ended. It intentionally does NOT match PHP's
|
|
// session.gc_maxlifetime (3600s) — that timeout is about when PHP reclaims the
|
|
// session file on disk, not about how quickly this check should stop treating a
|
|
// dead session as "still active".
|
|
// An explicit logout clears session_token to NULL, so back.php bypasses this.
|
|
//
|
|
// The staleness comparison is done entirely in SQL (session_last_seen vs MySQL's
|
|
// own NOW()), not in PHP, because session_last_seen is written with MySQL's
|
|
// NOW() and so is best compared against it.
|
|
//
|
|
// This originally worked around a timezone mismatch: config.php's $time_zone was
|
|
// never applied via date_default_timezone_set(), so PHP ran on UTC while the
|
|
// MySQL server ran on Asia/Bangkok. Pulling the timestamp into PHP and comparing
|
|
// with strtotime()/time() misread that Bangkok wall-clock string as UTC — 7 hours
|
|
// in the future — which made idle_seconds permanently negative and blocked every
|
|
// login. assets/utils/timezone.php now applies $time_zone to PHP and pins both
|
|
// PDO connections to the same offset, so the mismatch is gone; comparing in SQL
|
|
// is kept because it is still the most direct way to read a NOW()-written column.
|
|
define('SESSION_ACTIVE_GRACE_SECONDS', 120);
|
|
|
|
$sth_active = $pdo1->prepare(
|
|
"SELECT session_token,
|
|
(session_last_seen IS NOT NULL
|
|
AND session_last_seen > (NOW() - INTERVAL " . SESSION_ACTIVE_GRACE_SECONDS . " SECOND)) AS is_active
|
|
FROM user WHERE user_id = :uid LIMIT 1"
|
|
);
|
|
$sth_active->execute([':uid' => $user_id]);
|
|
$active_row = $sth_active->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!empty($active_row['session_token']) && !empty($active_row['is_active'])) {
|
|
$answer['message'] = 'This account is currently signed in on another device. Please sign out from that session first.';
|
|
exit(json_encode($answer));
|
|
}
|
|
|
|
// ── Step 4c: Claim session ────────────────────────────────────────────────────
|
|
// No active session found (or it has gone stale) — write a new token.
|
|
// db_auth.php compares session_token in the DB to the one in the PHP session,
|
|
// so any tab that still holds the old token is invalidated on its next request.
|
|
$session_token = bin2hex(random_bytes(32));
|
|
$sth_claim = $pdo1->prepare(
|
|
"UPDATE user
|
|
SET session_token = :token,
|
|
session_token_at = NOW(),
|
|
session_last_seen = NOW()
|
|
WHERE user_id = :uid"
|
|
);
|
|
$sth_claim->execute([
|
|
':token' => $session_token,
|
|
':uid' => $user_id,
|
|
]);
|
|
|
|
if ($sth_claim->rowCount() !== 1) {
|
|
$answer["message"] = "Login failed: user record not found.";
|
|
exit(json_encode($answer));
|
|
}
|
|
|
|
// ── Step 5a: Regenerate session ID ────────────────────────────────────────────
|
|
// session_regenerate_id(true) issues a brand-new session ID and deletes the old
|
|
// session file, preventing session fixation attacks where an attacker pre-sets
|
|
// a session ID before the user logs in.
|
|
session_regenerate_id(true);
|
|
|
|
// ── Step 5b: Issue CSRF token ─────────────────────────────────────────────────
|
|
// A fresh 256-bit token is generated here and stored in session. All subsequent
|
|
// POST requests from the authenticated app must include this token in the
|
|
// X-CSRF-Token header (validated by individual engine endpoints).
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
|
|
// ── Step 5c: Write authenticated login session ────────────────────────────────
|
|
// These keys are read by db_auth.php on every subsequent request to gate access.
|
|
// login_company_id is the user's default_company — used to scope all DB queries.
|
|
$_SESSION["login_status"] = 1;
|
|
$_SESSION['session_token'] = $session_token;
|
|
$_SESSION["login_user_id"] = (int)$temp["user_id"];
|
|
$_SESSION["login_username"] = $temp["username"];
|
|
$_SESSION["login_name"] = $temp["name"];
|
|
$_SESSION["login_surname"] = $temp["surname"];
|
|
$_SESSION["login_company_id"] = $temp["default_company"];
|
|
$_SESSION["login_profile_picture"] = $temp["profile_picture"] ?? '';
|
|
$_SESSION["login_license"] = $temp["license"] ?? 'user';
|
|
// Required by db_auth.php's per-request OTP integrity check. For skip_otp users
|
|
// (staff/viewer) this was never written by login_otp.php, so we set it here.
|
|
$_SESSION["otp"] = $otp;
|
|
// license='owner' means the user holds their own subscription — use user.app_access.
|
|
// license='user' means they were invited — use company_map_user.app_access instead.
|
|
$_SESSION["login_app_access"] = $temp["app_access"] ?? 'wms';
|
|
|
|
$role_sth = $pdo1->prepare(
|
|
"SELECT role, app_access FROM company_map_user
|
|
WHERE company_id = :company_id AND user_id = :user_id
|
|
LIMIT 1"
|
|
);
|
|
$role_sth->execute([
|
|
':company_id' => $_SESSION["login_company_id"],
|
|
':user_id' => $_SESSION["login_user_id"],
|
|
]);
|
|
$map_row = $role_sth->fetch(PDO::FETCH_ASSOC);
|
|
$_SESSION["login_role"] = $map_row['role'] ?? 'viewer';
|
|
|
|
if (($temp['license'] ?? 'owner') !== 'owner') {
|
|
$_SESSION["login_app_access"] = $map_row['app_access'] ?? 'wms';
|
|
}
|
|
|
|
// ── Step 6: Respond ───────────────────────────────────────────────────────────
|
|
$answer["success"] = 1;
|
|
$answer["message"] = "Login Complete!";
|
|
exit(json_encode($answer));
|