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

186 lines
9.8 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. Check session_token in DB — if non-NULL, another session is active;
* reject login with "account already logged in" message.
* 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: Block login if another session is already active ─────────────────
// If session_token is non-NULL AND was set within the last 8 hours, another
// session is active — reject. Tokens older than 8 hours are treated as
// abandoned (browser crash, PHP GC expiry, etc.) and cleared automatically.
$sth_token = $pdo1->prepare("SELECT session_token, session_token_at FROM user WHERE user_id = :uid LIMIT 1");
$sth_token->execute([':uid' => $user_id]);
$token_row = $sth_token->fetch(PDO::FETCH_ASSOC);
$existing_token = $token_row['session_token'] ?? null;
if (!empty($existing_token)) {
$token_age_hours = PHP_INT_MAX;
if (!empty($token_row['session_token_at'])) {
$token_age_hours = (time() - strtotime($token_row['session_token_at'])) / 3600;
}
if ($token_age_hours < 8) {
$answer["message"] = "This account is currently logged in on another device. Please wait for the other session to end.";
exit(json_encode($answer));
}
// Stale token — clear it and proceed with login
$pdo1->prepare("UPDATE user SET session_token = NULL, session_token_at = NULL WHERE user_id = :uid")
->execute([':uid' => $user_id]);
}
// ── Step 4b: 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 4c: Claim session — write token so no one else can log in ────────────
$session_token = bin2hex(random_bytes(32));
$pdo1->prepare("UPDATE user SET session_token = :token, session_token_at = NOW() WHERE user_id = :uid")
->execute([':token' => $session_token, ':uid' => $user_id]);
// ── 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));