Files
wms-app/app/login/api/engine/login_confirm.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

244 lines
13 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 → HTTP 401 "Wrong OTP! Please try again." After
* LOGIN_OTP_MAX_ATTEMPTS wrong codes the pending login is cleared and the
* user must enter the password again (code "login_restart").
* 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_user_id, password_verified_at, otpTime, otp_attempts, skip_otp
*
* 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)" } — HTTP 401
* (429 when throttled, 409 when signed in on another device)
*/
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';
rate_limit_guard($pdo1, [
['login_confirm_ip', rate_limit_client_ip(), 60, 900],
]);
// ── Step 1: Load session state written by login_otp.php ───────────────────────
// A pending login exists only after login_otp.php verified the password, and
// only for LOGIN_PENDING_SECONDS; anything else must start again from step 1.
if (!login_pending_valid()) {
$_SESSION = [];
login_fail(401, 'Your sign-in has expired. Please enter your username and password again.', ['code' => 'login_restart']);
}
$user_id = (int)$_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.
$otp = login_generate_otp((string)($temp["password"] ?? ''), (int)($_SESSION["otpTime"] ?? 0));
// ── 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;
// ── 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 (!hash_equals($otp, trim((string)($data["otp"] ?? ''))) || $otp_diff_minutes > 5) {
// Count wrong codes per issued OTP; the 6-digit code must not be
// guessable by brute force within its 5-minute window.
$_SESSION['otp_attempts'] = (int)($_SESSION['otp_attempts'] ?? 0) + 1;
if ($_SESSION['otp_attempts'] >= LOGIN_OTP_MAX_ATTEMPTS) {
$_SESSION = [];
login_fail(401, 'Too many incorrect OTP attempts. Please sign in again.', ['code' => 'login_restart']);
}
login_fail(401, "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)");
}
}
// ── 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'])) {
login_fail(409, 'This account is currently signed in on another device. Please sign out from that session first.');
}
// ── 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) {
$_SESSION = [];
login_fail(401, "Login failed: user record not found.", ['code' => 'login_restart']);
}
// ── 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);
// The pending-login keys are done with once the user is signed in.
unset($_SESSION['login_data'], $_SESSION['password_verified_at'], $_SESSION['otp_attempts'],
$_SESSION['otp_resends'], $_SESSION['reference'], $_SESSION['skip_otp']);
// ── 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));