Files
wms-app/app/login/api/engine/login_confirm.php
T
2026-08-03 11:17:41 +07:00

228 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';
// ── 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.
if (empty($_SESSION['skip_otp'])) {
if ($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. session_last_seen is written with MySQL's NOW(), and
// the MySQL server here runs on Asia/Bangkok time while PHP's default timezone is
// UTC (config.php's $time_zone is never applied via date_default_timezone_set()).
// Pulling the timestamp into PHP and comparing with strtotime()/time() silently
// misreads that Bangkok wall-clock string as UTC — 7 hours in the future — which
// made idle_seconds permanently negative and this check block every login,
// regardless of window size. Comparing inside MySQL sidesteps the mismatch
// without touching PHP's global timezone (which would ripple into every other
// date()/time() call in the app).
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));