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
This commit is contained in:
@@ -14,20 +14,25 @@
|
|||||||
*
|
*
|
||||||
* The OTP is a 6-digit TOTP derived from the user's current password hash via HMAC-SHA1,
|
* The OTP is a 6-digit TOTP derived from the user's current password hash via HMAC-SHA1,
|
||||||
* scoped to a 3-minute time step. It cannot be replayed after the window expires.
|
* scoped to a 3-minute time step. It cannot be replayed after the window expires.
|
||||||
* A human-readable reference number (6 uppercase letters) is also generated and emailed
|
* A random reference number (6 uppercase letters) is also generated and emailed so the
|
||||||
* so the user can confirm they received the correct OTP request.
|
* user can confirm they received the correct OTP request. It is not derived from the
|
||||||
|
* OTP: a derived reference let anyone who saw it recover the OTP offline.
|
||||||
*
|
*
|
||||||
* HTTP handler methods for thin AJAX endpoint wrappers:
|
* HTTP handler methods for thin AJAX endpoint wrappers:
|
||||||
* handleRequestOtp($user_id, $company_id)
|
* handleRequestOtp($user_id, $company_id) — signed-in profile page
|
||||||
|
* handleRequestOtpPublic($user_id, $company_id) — login page; same answer whether
|
||||||
|
* or not the account exists
|
||||||
* handleConfirmReset($user_id, $data)
|
* handleConfirmReset($user_id, $data)
|
||||||
*
|
*
|
||||||
* Session keys used (prefixed with 'reset_' to avoid collision with login OTP):
|
* Session keys used (prefixed with 'reset_' to avoid collision with login OTP):
|
||||||
* reset_otp, reset_otp_time, reset_reference, reset_user_id
|
* reset_otp, reset_otp_time, reset_reference, reset_user_id, reset_attempts
|
||||||
*
|
*
|
||||||
* Security:
|
* Security:
|
||||||
* - OTP is HMAC-derived from the current password hash — it changes when the password changes.
|
* - OTP is HMAC-derived from the current password hash — it changes when the password changes.
|
||||||
* - OTP is valid for OTP_EXPIRY_MINUTES (5) only; older OTPs are rejected with clearSession().
|
* - OTP is valid for OTP_EXPIRY_MINUTES (5) only; older OTPs are rejected with clearSession().
|
||||||
* - reset_user_id in session is verified against $user_id to prevent cross-user OTP reuse.
|
* - reset_user_id in session is verified against $user_id to prevent cross-user OTP reuse.
|
||||||
|
* - At most OTP_MAX_ATTEMPTS wrong entries per issued OTP, then it is discarded.
|
||||||
|
* - OTPs are compared with hash_equals().
|
||||||
* - Session is fully destroyed on successful reset, forcing re-authentication.
|
* - Session is fully destroyed on successful reset, forcing re-authentication.
|
||||||
* - All DB queries use PDO prepared statements with bound parameters.
|
* - All DB queries use PDO prepared statements with bound parameters.
|
||||||
* - AJAX handler methods output JSON via json_encode (XSS-safe).
|
* - AJAX handler methods output JSON via json_encode (XSS-safe).
|
||||||
@@ -43,6 +48,12 @@ class PasswordResetManager {
|
|||||||
/** OTP validity window in minutes — matches the login OTP window. */
|
/** OTP validity window in minutes — matches the login OTP window. */
|
||||||
const OTP_EXPIRY_MINUTES = 5;
|
const OTP_EXPIRY_MINUTES = 5;
|
||||||
|
|
||||||
|
/** Wrong OTP entries allowed per issued OTP before it is discarded. */
|
||||||
|
const OTP_MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
/** Answer shown on the login page whether or not the account exists. */
|
||||||
|
const PUBLIC_REQUEST_MESSAGE = "If an account matches, we've sent an OTP to its email.";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param PDO $pdo1 PDO connection to the wms database (user table).
|
* @param PDO $pdo1 PDO connection to the wms database (user table).
|
||||||
* @param PDO $pdo2 PDO connection to the company database (smtp_setting table).
|
* @param PDO $pdo2 PDO connection to the company database (smtp_setting table).
|
||||||
@@ -94,10 +105,10 @@ class PasswordResetManager {
|
|||||||
throw new \RuntimeException('No email address found for this account.');
|
throw new \RuntimeException('No email address found for this account.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 6-digit TOTP and a human-readable 6-letter reference number
|
// Generate 6-digit TOTP and a random 6-letter reference number
|
||||||
$otp_time = time();
|
$otp_time = time();
|
||||||
$otp = $this->generateOTP($user['password'], $otp_time);
|
$otp = $this->generateOTP($user['password'], $otp_time);
|
||||||
$reference_number = $this->numberToLetters((int) $this->generateOTP($otp, $otp_time));
|
$reference_number = $this->randomReference();
|
||||||
|
|
||||||
// Send via the mailer module (uses company SMTP or falls back to system default)
|
// Send via the mailer module (uses company SMTP or falls back to system default)
|
||||||
require_once $this->include_url . '/assets/utils/module/mailer.php';
|
require_once $this->include_url . '/assets/utils/module/mailer.php';
|
||||||
@@ -124,6 +135,7 @@ class PasswordResetManager {
|
|||||||
$_SESSION['reset_otp_time'] = $otp_time;
|
$_SESSION['reset_otp_time'] = $otp_time;
|
||||||
$_SESSION['reset_reference'] = $reference_number;
|
$_SESSION['reset_reference'] = $reference_number;
|
||||||
$_SESSION['reset_user_id'] = $user_id;
|
$_SESSION['reset_user_id'] = $user_id;
|
||||||
|
$_SESSION['reset_attempts'] = 0;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'masked_email' => $this->maskEmail($user['email']),
|
'masked_email' => $this->maskEmail($user['email']),
|
||||||
@@ -131,6 +143,24 @@ class PasswordResetManager {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a reset that can never succeed, for a login-page request whose
|
||||||
|
* username/email matches no account. The session then looks exactly like a
|
||||||
|
* real request (random unguessable OTP, reset_user_id 0), so the confirm step
|
||||||
|
* answers "Incorrect OTP" instead of revealing that the account is missing.
|
||||||
|
*
|
||||||
|
* @return string Random 6-letter reference, same shape as a real one.
|
||||||
|
*/
|
||||||
|
public function startDecoy(): string {
|
||||||
|
$reference = $this->randomReference();
|
||||||
|
$_SESSION['reset_otp'] = bin2hex(random_bytes(16));
|
||||||
|
$_SESSION['reset_otp_time'] = time();
|
||||||
|
$_SESSION['reset_reference'] = $reference;
|
||||||
|
$_SESSION['reset_user_id'] = 0;
|
||||||
|
$_SESSION['reset_attempts'] = 0;
|
||||||
|
return $reference;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify the OTP and force-set a new password via PasswordManager.
|
* Verify the OTP and force-set a new password via PasswordManager.
|
||||||
*
|
*
|
||||||
@@ -170,8 +200,14 @@ class PasswordResetManager {
|
|||||||
throw new \InvalidArgumentException('OTP has expired. Please request a new one.');
|
throw new \InvalidArgumentException('OTP has expired. Please request a new one.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify OTP value
|
// Verify OTP value — at most OTP_MAX_ATTEMPTS wrong entries per issued OTP,
|
||||||
if (trim($otp_input) !== $_SESSION['reset_otp']) {
|
// so the 6-digit code cannot be brute-forced inside its 5-minute window.
|
||||||
|
if (!hash_equals((string)$_SESSION['reset_otp'], trim($otp_input)) || $user_id <= 0) {
|
||||||
|
$_SESSION['reset_attempts'] = (int)($_SESSION['reset_attempts'] ?? 0) + 1;
|
||||||
|
if ($_SESSION['reset_attempts'] >= self::OTP_MAX_ATTEMPTS) {
|
||||||
|
$this->clearSession();
|
||||||
|
throw new \InvalidArgumentException('Too many incorrect OTP attempts. Please request a new OTP.');
|
||||||
|
}
|
||||||
throw new \InvalidArgumentException('Incorrect OTP. Please try again.');
|
throw new \InvalidArgumentException('Incorrect OTP. Please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +270,39 @@ class PasswordResetManager {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the login-page request-OTP call. The answer is the same whether or
|
||||||
|
* not the username/email matches an account (no account enumeration): no
|
||||||
|
* masked email, a generic message and a reference number. Mail failures are
|
||||||
|
* logged, not reported, for the same reason.
|
||||||
|
*
|
||||||
|
* On success (always): { success: 1, message: PUBLIC_REQUEST_MESSAGE, reference: "ABCDEF" }
|
||||||
|
*
|
||||||
|
* @param int|null $user_id Resolved account, or null when nothing matched.
|
||||||
|
* @param int $company_id Company SMTP scope (0 = use system default).
|
||||||
|
*/
|
||||||
|
public function handleRequestOtpPublic(?int $user_id, int $company_id = 0): void {
|
||||||
|
|
||||||
|
$reference = null;
|
||||||
|
if ($user_id) {
|
||||||
|
try {
|
||||||
|
$reference = $this->requestOtp($user_id, $company_id)['reference'];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('[PasswordResetManager::handleRequestOtpPublic] ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($reference === null) {
|
||||||
|
$reference = $this->startDecoy();
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => 1,
|
||||||
|
'message' => self::PUBLIC_REQUEST_MESSAGE,
|
||||||
|
'reference' => $reference,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an AJAX confirm-reset call and echo a JSON response.
|
* Handle an AJAX confirm-reset call and echo a JSON response.
|
||||||
*
|
*
|
||||||
@@ -312,23 +381,17 @@ class PasswordResetManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a positive integer into a base-26 uppercase letter string.
|
* Random 6-letter uppercase reference code (e.g. "BCDFHJ") for the reset email
|
||||||
|
* and the confirmation screen. Carries no information about the OTP.
|
||||||
*
|
*
|
||||||
* Used to turn the numeric reference OTP into a human-friendly 6-letter
|
* @return string 6-character uppercase string.
|
||||||
* reference code (e.g. 123456 → "BCDFHJ") for inclusion in the reset email.
|
|
||||||
* The result is left-padded with 'A' to always return a 6-character string.
|
|
||||||
*
|
|
||||||
* @param int $num Positive integer to convert.
|
|
||||||
* @return string 6-character uppercase string (e.g. "AAAABC").
|
|
||||||
*/
|
*/
|
||||||
private function numberToLetters(int $num): string {
|
private function randomReference(): string {
|
||||||
$result = '';
|
$result = '';
|
||||||
while ($num > 0) {
|
for ($i = 0; $i < 6; $i++) {
|
||||||
$mod = ($num - 1) % 26;
|
$result .= chr(65 + random_int(0, 25));
|
||||||
$result = chr(65 + $mod) . $result;
|
|
||||||
$num = intval(($num - $mod) / 26);
|
|
||||||
}
|
}
|
||||||
return str_pad($result, 6, 'A', STR_PAD_LEFT);
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -356,14 +419,15 @@ class PasswordResetManager {
|
|||||||
*
|
*
|
||||||
* Called on OTP expiry (to invalidate the request) and on successful
|
* Called on OTP expiry (to invalidate the request) and on successful
|
||||||
* reset (before session_destroy). Does not destroy the full session —
|
* reset (before session_destroy). Does not destroy the full session —
|
||||||
* only the 4 reset-specific keys are unset.
|
* only the reset-specific keys are unset.
|
||||||
*/
|
*/
|
||||||
private function clearSession(): void {
|
private function clearSession(): void {
|
||||||
unset(
|
unset(
|
||||||
$_SESSION['reset_otp'],
|
$_SESSION['reset_otp'],
|
||||||
$_SESSION['reset_otp_time'],
|
$_SESSION['reset_otp_time'],
|
||||||
$_SESSION['reset_reference'],
|
$_SESSION['reset_reference'],
|
||||||
$_SESSION['reset_user_id']
|
$_SESSION['reset_user_id'],
|
||||||
|
$_SESSION['reset_attempts']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* rate_limit.php — DB-backed request throttle for the unauthenticated login,
|
||||||
|
* OTP and registration endpoints.
|
||||||
|
*
|
||||||
|
* Counters live in wms.auth_throttle (created by setup.php), not in the PHP
|
||||||
|
* session: an attacker simply drops the session cookie to reset a session
|
||||||
|
* counter. Each (bucket, key) pair counts hits in a fixed window; keys are
|
||||||
|
* stored as SHA-256 hashes so the table never holds raw IPs or emails.
|
||||||
|
*
|
||||||
|
* The helper fails open: if the table is missing or the query fails, the error
|
||||||
|
* is logged and the request is allowed, so a schema problem can never lock
|
||||||
|
* every user out of the login page.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* require_once '../../../assets/utils/rate_limit.php';
|
||||||
|
* rate_limit_guard($pdo1, [
|
||||||
|
* ['login_ip', rate_limit_client_ip(), 20, 900],
|
||||||
|
* ['login_user', $username, 10, 900],
|
||||||
|
* ]);
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!function_exists('rate_limit_client_ip')) {
|
||||||
|
/**
|
||||||
|
* The client address as Apache sees it. X-Forwarded-For is deliberately not
|
||||||
|
* trusted here: any client can send it, which would let them pick a fresh
|
||||||
|
* key for every request.
|
||||||
|
*/
|
||||||
|
function rate_limit_client_ip(): string {
|
||||||
|
return (string)($_SERVER['REMOTE_ADDR'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('rate_limit_hit')) {
|
||||||
|
/**
|
||||||
|
* Count one hit for ($bucket, $key) and report whether the limit is exceeded.
|
||||||
|
*
|
||||||
|
* @param PDO $pdo Connection to the wms (auth) database.
|
||||||
|
* @param string $bucket Endpoint/purpose name, e.g. 'login_ip'.
|
||||||
|
* @param string $key Raw key (IP, normalised username/email, user id).
|
||||||
|
* @param int $max Hits allowed per window.
|
||||||
|
* @param int $window_seconds Window length in seconds.
|
||||||
|
* @return bool true when this hit is over the limit.
|
||||||
|
*/
|
||||||
|
function rate_limit_hit(PDO $pdo, string $bucket, string $key, int $max, int $window_seconds): bool {
|
||||||
|
if ($key === '') return false;
|
||||||
|
$key_hash = hash('sha256', $bucket . '|' . $key);
|
||||||
|
try {
|
||||||
|
// One statement per hit: a new row starts at 1; an existing row either
|
||||||
|
// restarts its window (expired) or counts up. MySQL applies the SET
|
||||||
|
// list left to right, so `hits` still sees the old window_start.
|
||||||
|
$pdo->prepare(
|
||||||
|
"INSERT INTO auth_throttle (bucket, key_hash, window_start, hits)
|
||||||
|
VALUES (:b, :k, NOW(), 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
hits = IF(window_start < NOW() - INTERVAL :w1 SECOND, 1, hits + 1),
|
||||||
|
window_start = IF(window_start < NOW() - INTERVAL :w2 SECOND, NOW(), window_start)"
|
||||||
|
)->execute([':b' => $bucket, ':k' => $key_hash, ':w1' => $window_seconds, ':w2' => $window_seconds]);
|
||||||
|
|
||||||
|
$sth = $pdo->prepare("SELECT hits FROM auth_throttle WHERE bucket = :b AND key_hash = :k");
|
||||||
|
$sth->execute([':b' => $bucket, ':k' => $key_hash]);
|
||||||
|
return (int)$sth->fetchColumn() > $max;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('[rate_limit] throttle check skipped (' . $bucket . '): ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('rate_limit_guard')) {
|
||||||
|
/**
|
||||||
|
* Count every check and stop the request with HTTP 429 if any is over its
|
||||||
|
* limit. Each check is [bucket, key, max, window_seconds].
|
||||||
|
*/
|
||||||
|
function rate_limit_guard(PDO $pdo, array $checks): void {
|
||||||
|
$limited = false;
|
||||||
|
foreach ($checks as [$bucket, $key, $max, $window]) {
|
||||||
|
if (rate_limit_hit($pdo, $bucket, (string)$key, (int)$max, (int)$window)) {
|
||||||
|
$limited = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($limited) {
|
||||||
|
rate_limit_reject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('rate_limit_reject')) {
|
||||||
|
/** Answer 429 with the same generic message everywhere and stop. */
|
||||||
|
function rate_limit_reject(): void {
|
||||||
|
http_response_code(429);
|
||||||
|
header('Retry-After: 300');
|
||||||
|
exit(json_encode([
|
||||||
|
'success' => 0,
|
||||||
|
'message' => 'Too many requests. Please wait a few minutes and try again.',
|
||||||
|
'code' => 'rate_limited',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ $token = $_SESSION['accept_invite_token'];
|
|||||||
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
|
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
|
if (empty($csrf) || !hash_equals((string)($_SESSION['csrf_token'] ?? ''), $csrf)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
$answer['message'] = 'Invalid request.';
|
$answer['message'] = 'Invalid request.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ $token = $_SESSION['invited_token'];
|
|||||||
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
|
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
|
if (empty($csrf) || !hash_equals((string)($_SESSION['csrf_token'] ?? ''), $csrf)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
$answer['message'] = 'Invalid request.';
|
$answer['message'] = 'Invalid request.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
* 4. Check both conditions that must be true for the OTP to be valid:
|
* 4. Check both conditions that must be true for the OTP to be valid:
|
||||||
* a. The submitted OTP matches the re-derived expected value.
|
* a. The submitted OTP matches the re-derived expected value.
|
||||||
* b. The elapsed time since otpTime is ≤ 5 minutes.
|
* b. The elapsed time since otpTime is ≤ 5 minutes.
|
||||||
* Fail either → return "Wrong OTP! Please try again."
|
* 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:
|
* 5. On success:
|
||||||
* a. Concurrent-session check — if the account already has a session_token
|
* a. Concurrent-session check — if the account already has a session_token
|
||||||
* set and session_last_seen is within SESSION_ACTIVE_GRACE_SECONDS
|
* set and session_last_seen is within SESSION_ACTIVE_GRACE_SECONDS
|
||||||
@@ -42,7 +44,7 @@
|
|||||||
* cannot forge a valid OTP without also knowing the password hash.
|
* cannot forge a valid OTP without also knowing the password hash.
|
||||||
*
|
*
|
||||||
* Session keys read:
|
* Session keys read:
|
||||||
* login_data['username'], login_data['password'], login_user_id, otpTime
|
* login_user_id, password_verified_at, otpTime, otp_attempts, skip_otp
|
||||||
*
|
*
|
||||||
* Session keys written:
|
* Session keys written:
|
||||||
* login_status, login_username, login_name, login_surname, login_company_id,
|
* login_status, login_username, login_name, login_surname, login_company_id,
|
||||||
@@ -50,7 +52,8 @@
|
|||||||
*
|
*
|
||||||
* Response JSON:
|
* Response JSON:
|
||||||
* On success: { "success": 1, "message": "Login Complete!" }
|
* On success: { "success": 1, "message": "Login Complete!" }
|
||||||
* On failure: { "message": "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)" }
|
* 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 '../../../session.php';
|
||||||
@@ -59,11 +62,21 @@ require_once '../../../preset.php';
|
|||||||
define('UNAUTHENTICATED_ROUTE', true);
|
define('UNAUTHENTICATED_ROUTE', true);
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/otp_policy.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 ───────────────────────
|
// ── Step 1: Load session state written by login_otp.php ───────────────────────
|
||||||
$data["username"] = $_SESSION["login_data"]['username'];
|
// A pending login exists only after login_otp.php verified the password, and
|
||||||
$data["password"] = $_SESSION["login_data"]['password'];
|
// only for LOGIN_PENDING_SECONDS; anything else must start again from step 1.
|
||||||
$user_id = $_SESSION["login_user_id"];
|
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 ───────
|
// ── 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 = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
|
||||||
@@ -74,18 +87,7 @@ $temp = $sth->fetch(PDO::FETCH_ASSOC);
|
|||||||
// Uses $_SESSION['otpTime'] (set when the OTP was generated) as the TOTP
|
// 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
|
// 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.
|
// request_new_otp.php — any change to one must be reflected in all three.
|
||||||
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
|
$otp = login_generate_otp((string)($temp["password"] ?? ''), (int)($_SESSION["otpTime"] ?? 0));
|
||||||
$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 ──────────────────────
|
// ── Step 3b: Calculate elapsed time since OTP was issued ──────────────────────
|
||||||
// otpTime is the Unix timestamp stored by login_otp.php when the OTP was sent.
|
// otpTime is the Unix timestamp stored by login_otp.php when the OTP was sent.
|
||||||
@@ -95,10 +97,6 @@ $now = time();
|
|||||||
$otp_diff_seconds = max(0, $now - $otp_time);
|
$otp_diff_seconds = max(0, $now - $otp_time);
|
||||||
$otp_diff_minutes = $otp_diff_seconds / 60.0;
|
$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 ─────────────────────────────────────
|
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
|
||||||
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
|
// 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,
|
// so they never receive or enter an OTP. Admin/owner always go through this check,
|
||||||
@@ -109,9 +107,15 @@ if (empty($_SESSION['skip_otp'])) {
|
|||||||
if (!empty($user_id)) {
|
if (!empty($user_id)) {
|
||||||
otp_log_bypass($user_id, 'login_confirm');
|
otp_log_bypass($user_id, 'login_confirm');
|
||||||
}
|
}
|
||||||
} elseif ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
} elseif (!hash_equals($otp, trim((string)($data["otp"] ?? ''))) || $otp_diff_minutes > 5) {
|
||||||
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
|
// Count wrong codes per issued OTP; the 6-digit code must not be
|
||||||
exit(json_encode($answer));
|
// 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)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,8 +161,7 @@ $sth_active->execute([':uid' => $user_id]);
|
|||||||
$active_row = $sth_active->fetch(PDO::FETCH_ASSOC);
|
$active_row = $sth_active->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
if (!empty($active_row['session_token']) && !empty($active_row['is_active'])) {
|
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.';
|
login_fail(409, 'This account is currently signed in on another device. Please sign out from that session first.');
|
||||||
exit(json_encode($answer));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 4c: Claim session ────────────────────────────────────────────────────
|
// ── Step 4c: Claim session ────────────────────────────────────────────────────
|
||||||
@@ -179,8 +182,8 @@ $sth_claim->execute([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if ($sth_claim->rowCount() !== 1) {
|
if ($sth_claim->rowCount() !== 1) {
|
||||||
$answer["message"] = "Login failed: user record not found.";
|
$_SESSION = [];
|
||||||
exit(json_encode($answer));
|
login_fail(401, "Login failed: user record not found.", ['code' => 'login_restart']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5a: Regenerate session ID ────────────────────────────────────────────
|
// ── Step 5a: Regenerate session ID ────────────────────────────────────────────
|
||||||
@@ -189,6 +192,10 @@ if ($sth_claim->rowCount() !== 1) {
|
|||||||
// a session ID before the user logs in.
|
// a session ID before the user logs in.
|
||||||
session_regenerate_id(true);
|
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 ─────────────────────────────────────────────────
|
// ── Step 5b: Issue CSRF token ─────────────────────────────────────────────────
|
||||||
// A fresh 256-bit token is generated here and stored in session. All subsequent
|
// 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
|
// POST requests from the authenticated app must include this token in the
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
* 1. Resolve user_id by username or email (case-insensitive).
|
* 1. Resolve user_id by username or email (case-insensitive).
|
||||||
* 2. Fetch hashed password and full user record.
|
* 2. Fetch hashed password and full user record.
|
||||||
* 3. Verify submitted password via password_verify().
|
* 3. Verify submitted password via password_verify().
|
||||||
* 4. On failure → clear cookies, return "Incorrect Password".
|
* 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:
|
* 5. On success → run the following pre-login checks in order:
|
||||||
* a. Email format guard (malformed email → block with message).
|
* a. Email format guard (malformed email → block with message).
|
||||||
* b. Unverified account (status = 'pending'):
|
* b. Unverified account (status = 'pending'):
|
||||||
@@ -31,17 +32,20 @@
|
|||||||
* - Note: 'support' user and 'lord' licence bypass this check.
|
* - Note: 'support' user and 'lord' licence bypass this check.
|
||||||
* e. Licence expiry check: if now > $expire + 1 day → return "expire".
|
* 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).
|
* 6. Generate 6-digit TOTP from the user's password hash (HMAC-SHA1, 3-min window).
|
||||||
* 7. Generate a 6-letter human-readable reference number from the TOTP.
|
* 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.
|
* 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.
|
* If no SMTP configured → skip email, set skip_otp flag in response.
|
||||||
* 9. Clear session and repopulate with OTP state:
|
* 9. Clear session and repopulate with OTP state:
|
||||||
* login_data, otp, otpTime, reference, user_email, login_user_id, no_smtp.
|
* 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!" }.
|
* 10. Return { success: 1, skip_otp: bool, message: "Login Complete!" }.
|
||||||
* When skip_otp=true the login page skips the OTP step and calls
|
* When skip_otp=true the login page skips the OTP step and calls
|
||||||
* login_confirm.php directly.
|
* login_confirm.php directly.
|
||||||
*
|
*
|
||||||
* Session keys written:
|
* Session keys written:
|
||||||
* login_data — original { username, password } for request_new_otp.php
|
* 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
|
* otp — the generated TOTP value
|
||||||
* otpTime — Unix timestamp the OTP was generated (used for expiry check)
|
* otpTime — Unix timestamp the OTP was generated (used for expiry check)
|
||||||
* reference — 6-letter reference code shown on the OTP screen
|
* reference — 6-letter reference code shown on the OTP screen
|
||||||
@@ -51,7 +55,7 @@
|
|||||||
*
|
*
|
||||||
* Response JSON:
|
* Response JSON:
|
||||||
* On success: { "success": 1, "skip_otp": bool, "message": "Login Complete!" }
|
* On success: { "success": 1, "skip_otp": bool, "message": "Login Complete!" }
|
||||||
* On failure: { "message": "<reason>" }
|
* On failure: { "message": "<reason>" } with HTTP 401/403 (429 when throttled)
|
||||||
* Special: { "message": "wait" } — device pending whitelist approval
|
* Special: { "message": "wait" } — device pending whitelist approval
|
||||||
* { "message": "block" } — device is blacklisted
|
* { "message": "block" } — device is blacklisted
|
||||||
* { "expire": "expire" } — licence has expired
|
* { "expire": "expire" } — licence has expired
|
||||||
@@ -63,28 +67,36 @@ require_once '../../../preset.php';
|
|||||||
define('UNAUTHENTICATED_ROUTE', true);
|
define('UNAUTHENTICATED_ROUTE', true);
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/otp_policy.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) ────────
|
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
|
||||||
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
|
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
|
||||||
$sth->execute(array(strtolower($data["username"])));
|
$sth->execute(array($username));
|
||||||
$user_id = $sth->fetchColumn();
|
$user_id = $sth->fetchColumn();
|
||||||
|
|
||||||
$username = strtolower($data["username"]);
|
|
||||||
|
|
||||||
// ── Step 2: Fetch the user's hashed password + lockout state ─────────────────
|
// ── 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 = $pdo1->prepare("SELECT password, login_attempts, locked_until FROM user WHERE username = ? OR email = ? LIMIT 1;");
|
||||||
$sth->execute(array($username, $username));
|
$sth->execute(array($username, $username));
|
||||||
$temp = $sth->fetch(PDO::FETCH_ASSOC);
|
$temp = $sth->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// ── Step 2a: Lockout check — only when the username resolves to a real user ──
|
// ── Step 2a: Lockout check — only when the username resolves to a real user ──
|
||||||
// We only block here when $user_id is set (valid username) to avoid leaking
|
// A locked account gets the same generic answer as a wrong password, so the
|
||||||
// whether an account exists via a different error message.
|
// lockout cannot be used to confirm that an account exists.
|
||||||
if ($user_id && !empty($temp['locked_until'])) {
|
if ($user_id && !empty($temp['locked_until'])) {
|
||||||
if (strtotime($temp['locked_until']) > time()) {
|
if (strtotime($temp['locked_until']) > time()) {
|
||||||
// Still within the lockout window — reject
|
// Still within the lockout window — reject
|
||||||
$retry_at = date('H:i', strtotime($temp['locked_until']));
|
login_fail(401, LOGIN_GENERIC_FAILURE);
|
||||||
$answer['message'] = "Too many failed attempts. Please try again after {$retry_at}.";
|
|
||||||
exit(json_encode($answer));
|
|
||||||
} else {
|
} else {
|
||||||
// Lockout has expired — reset counter so they get a fresh 10 attempts
|
// 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")
|
$pdo1->prepare("UPDATE user SET login_attempts = 0, locked_until = NULL WHERE user_id = :id")
|
||||||
@@ -94,7 +106,7 @@ if ($user_id && !empty($temp['locked_until'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 3–4: Verify password — exit with error on mismatch ──────────────────
|
// ── Step 3–4: Verify password — exit with error on mismatch ──────────────────
|
||||||
if (password_verify(trim($data["password"]), $temp["password"])) {
|
if ($temp && password_verify(trim((string)($data["password"] ?? '')), $temp["password"])) {
|
||||||
|
|
||||||
// ── Reset lockout on successful password verification ─────────────────────
|
// ── Reset lockout on successful password verification ─────────────────────
|
||||||
if ($user_id) {
|
if ($user_id) {
|
||||||
@@ -120,8 +132,8 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
// Blocks accounts with a malformed email (e.g. set by admin without @) so
|
// Blocks accounts with a malformed email (e.g. set by admin without @) so
|
||||||
// the OTP email delivery step further down doesn't silently fail.
|
// the OTP email delivery step further down doesn't silently fail.
|
||||||
if (strpos($user_email, "@") === false) {
|
if (strpos($user_email, "@") === false) {
|
||||||
$answer["message"] = "<b>" . $user_email . "</b> is not eligible email, please contact your administrator to change your email.";
|
// The message is rendered as HTML by bootbox — escape the stored value.
|
||||||
exit(json_encode($answer));
|
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') ─────────────────────
|
// ── Step 5c: Unverified account (status = 'pending') ─────────────────────
|
||||||
@@ -166,6 +178,7 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
'key' => $pinkey,
|
'key' => $pinkey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
http_response_code(403);
|
||||||
if ($mail_sent) {
|
if ($mail_sent) {
|
||||||
$answer["message"] = "Your email is not verified. We've sent a new verification link to your inbox — please check your email.";
|
$answer["message"] = "Your email is not verified. We've sent a new verification link to your inbox — please check your email.";
|
||||||
} else {
|
} else {
|
||||||
@@ -177,8 +190,7 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
|
|
||||||
// ── Step 5d: Deactivated account ─────────────────────────────────────────
|
// ── Step 5d: Deactivated account ─────────────────────────────────────────
|
||||||
if ($r["status"] === "not activated") {
|
if ($r["status"] === "not activated") {
|
||||||
$answer["message"] = "Your account has been deactivated. Please contact your administrator.";
|
login_fail(403, "Your account has been deactivated. Please contact your administrator.");
|
||||||
exit(json_encode($answer));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5e: Secure-login device whitelist check ──────────────────────────
|
// ── Step 5e: Secure-login device whitelist check ──────────────────────────
|
||||||
@@ -202,11 +214,13 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
$s->execute(array(":cookie" => $data["cookie"], ":ip" => $_SERVER["REMOTE_ADDR"]));
|
$s->execute(array(":cookie" => $data["cookie"], ":ip" => $_SERVER["REMOTE_ADDR"]));
|
||||||
|
|
||||||
session_destroy();
|
session_destroy();
|
||||||
|
http_response_code(403);
|
||||||
$answer["message"] = "wait";
|
$answer["message"] = "wait";
|
||||||
setcookie("u", "", time() - 1, "/");
|
setcookie("u", "", time() - 1, "/");
|
||||||
setcookie("h1", "", time() - 1, "/");
|
setcookie("h1", "", time() - 1, "/");
|
||||||
setcookie("h2", "", time() - 1, "/");
|
setcookie("h2", "", time() - 1, "/");
|
||||||
echo json_encode($answer);
|
// Stop here: the session is gone, nothing below may run.
|
||||||
|
exit(json_encode($answer));
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
@@ -216,6 +230,7 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
|
|
||||||
// Device explicitly blocked by admin
|
// Device explicitly blocked by admin
|
||||||
session_destroy();
|
session_destroy();
|
||||||
|
http_response_code(403);
|
||||||
$answer["message"] = "block";
|
$answer["message"] = "block";
|
||||||
setcookie("u", "", time() - 1, "/");
|
setcookie("u", "", time() - 1, "/");
|
||||||
setcookie("h1", "", time() - 1, "/");
|
setcookie("h1", "", time() - 1, "/");
|
||||||
@@ -223,11 +238,13 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
echo json_encode($answer);
|
echo json_encode($answer);
|
||||||
|
|
||||||
$deviceDecision = ['type' => 'BLOCKED', 'status' => 0];
|
$deviceDecision = ['type' => 'BLOCKED', 'status' => 0];
|
||||||
|
exit;
|
||||||
|
|
||||||
} else if ($coo["status"] == "1") {
|
} else if ($coo["status"] == "1") {
|
||||||
|
|
||||||
// Device registered but not yet approved — notify admin
|
// Device registered but not yet approved — notify admin
|
||||||
session_destroy();
|
session_destroy();
|
||||||
|
http_response_code(403);
|
||||||
$answer["message"] = "wait";
|
$answer["message"] = "wait";
|
||||||
setcookie("u", "", time() - 1, "/");
|
setcookie("u", "", time() - 1, "/");
|
||||||
setcookie("h1", "", time() - 1, "/");
|
setcookie("h1", "", time() - 1, "/");
|
||||||
@@ -250,7 +267,9 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
// If the licence expired more than 1 day ago, reject the login.
|
// If the licence expired more than 1 day ago, reject the login.
|
||||||
if (strtotime("now") > strtotime($expire . " + 1 day")) {
|
if (strtotime("now") > strtotime($expire . " + 1 day")) {
|
||||||
session_destroy();
|
session_destroy();
|
||||||
|
http_response_code(403);
|
||||||
$answer["expire"] = "expire";
|
$answer["expire"] = "expire";
|
||||||
|
$answer["message"] = "Your licence has expired. Please contact your administrator.";
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +292,9 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
|
|
||||||
if (!$requires_otp) {
|
if (!$requires_otp) {
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
$_SESSION['login_data'] = $data;
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['login_data'] = ['username' => $username];
|
||||||
|
$_SESSION['password_verified_at'] = time();
|
||||||
$_SESSION['login_user_id'] = $user_id;
|
$_SESSION['login_user_id'] = $user_id;
|
||||||
$_SESSION['otpTime'] = time();
|
$_SESSION['otpTime'] = time();
|
||||||
$_SESSION['skip_otp'] = true;
|
$_SESSION['skip_otp'] = true;
|
||||||
@@ -287,38 +308,12 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
// The secret key is the user's current password hash, so the OTP is unique
|
// The secret key is the user's current password hash, so the OTP is unique
|
||||||
// per user and automatically invalidated if the password changes.
|
// per user and automatically invalidated if the password changes.
|
||||||
// time_step=180 means the OTP window is 3 minutes (same counter for 3 min).
|
// time_step=180 means the OTP window is 3 minutes (same counter for 3 min).
|
||||||
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
|
$otpTime = time();
|
||||||
|
$otp = login_generate_otp($temp["password"], $otpTime);
|
||||||
global $otpTime;
|
|
||||||
|
|
||||||
$otpTime = time(); // captured globally so it can be stored in session
|
|
||||||
|
|
||||||
$counter = floor($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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 7: Generate 6-letter reference number ───────────────────────────
|
// ── Step 7: Generate 6-letter reference number ───────────────────────────
|
||||||
// Converts a second TOTP (derived from the first OTP as key) to a base-26
|
// Random, shown on the OTP screen so the user can match it to the email.
|
||||||
// uppercase letter string. Shown on the OTP screen so the user can confirm
|
$reference_number = login_random_reference();
|
||||||
// they received the correct email.
|
|
||||||
function numberToLetters($num) {
|
|
||||||
$result = '';
|
|
||||||
while ($num > 0) {
|
|
||||||
$mod = ($num - 1) % 26;
|
|
||||||
$result = chr(65 + $mod) . $result;
|
|
||||||
$num = intval(($num - $mod) / 26);
|
|
||||||
}
|
|
||||||
return str_pad($result, 6, 'A', STR_PAD_LEFT);
|
|
||||||
}
|
|
||||||
|
|
||||||
$otp = generateOTP($temp["password"]);
|
|
||||||
$reference_number = numberToLetters(generateOTP($otp));
|
|
||||||
|
|
||||||
// ── Step 8: Look up company SMTP and send OTP email ──────────────────────
|
// ── Step 8: Look up company SMTP and send OTP email ──────────────────────
|
||||||
// Uses the SMTP settings saved for the user's default_company.
|
// Uses the SMTP settings saved for the user's default_company.
|
||||||
@@ -371,8 +366,12 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
// The full session is cleared first to prevent session fixation — any data
|
// The full session is cleared first to prevent session fixation — any data
|
||||||
// from a previous partial login attempt is discarded before writing new state.
|
// from a previous partial login attempt is discarded before writing new state.
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
|
session_regenerate_id(true);
|
||||||
|
|
||||||
$_SESSION["login_data"] = $data; // preserved for request_new_otp.php resend flow
|
$_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["otp"] = $otp; // expected value for login_confirm.php to verify
|
||||||
$_SESSION["otpTime"] = $otpTime; // timestamp for the 5-minute expiry window
|
$_SESSION["otpTime"] = $otpTime; // timestamp for the 5-minute expiry window
|
||||||
$_SESSION["reference"] = $reference_number; // shown on OTP input screen
|
$_SESSION["reference"] = $reference_number; // shown on OTP input screen
|
||||||
@@ -394,27 +393,23 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
// ── Password mismatch ─────────────────────────────────────────────────────
|
// ── Password mismatch ─────────────────────────────────────────────────────
|
||||||
// Only increment the counter when the username is valid — wrong usernames
|
// 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.
|
// 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) {
|
if ($user_id) {
|
||||||
$attempts = (int)($temp['login_attempts'] ?? 0) + 1;
|
$attempts = (int)($temp['login_attempts'] ?? 0) + 1;
|
||||||
if ($attempts >= 5) {
|
if ($attempts >= 5) {
|
||||||
$locked_until = date('Y-m-d H:i:s', strtotime('+30 minutes'));
|
$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")
|
$pdo1->prepare("UPDATE user SET login_attempts = :a, locked_until = :l WHERE user_id = :id")
|
||||||
->execute([':a' => $attempts, ':l' => $locked_until, ':id' => $user_id]);
|
->execute([':a' => $attempts, ':l' => $locked_until, ':id' => $user_id]);
|
||||||
$retry_at = date('H:i', strtotime($locked_until));
|
|
||||||
$answer['message'] = "Too many failed attempts. Please try again after {$retry_at}.";
|
|
||||||
} else {
|
} else {
|
||||||
$pdo1->prepare("UPDATE user SET login_attempts = :a WHERE user_id = :id")
|
$pdo1->prepare("UPDATE user SET login_attempts = :a WHERE user_id = :id")
|
||||||
->execute([':a' => $attempts, ':id' => $user_id]);
|
->execute([':a' => $attempts, ':id' => $user_id]);
|
||||||
$answer['message'] = "Incorrect Password";
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
$answer['message'] = "Incorrect Username";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setcookie("u", "", time() - 1, "/");
|
setcookie("u", "", time() - 1, "/");
|
||||||
setcookie("h1", "", time() - 1, "/");
|
setcookie("h1", "", time() - 1, "/");
|
||||||
setcookie("h2", "", time() - 1, "/");
|
setcookie("h2", "", time() - 1, "/");
|
||||||
exit(json_encode($answer));
|
login_fail(401, LOGIN_GENERIC_FAILURE);
|
||||||
}
|
}
|
||||||
|
|
||||||
$answer["success"] = 1;
|
$answer["success"] = 1;
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ require_once '../../../config.php';
|
|||||||
require_once '../../../dbconn.php';
|
require_once '../../../dbconn.php';
|
||||||
require_once '../../../assets/utils/db_helpers.php';
|
require_once '../../../assets/utils/db_helpers.php';
|
||||||
require_once '../../../assets/utils/otp_policy.php';
|
require_once '../../../assets/utils/otp_policy.php';
|
||||||
|
require_once '../../../assets/utils/rate_limit.php';
|
||||||
|
require_once '../../../assets/utils/secret_box.php';
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ if ($sth->fetchColumn() !== 'owner') {
|
|||||||
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
|
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
|
if (empty($csrf) || !hash_equals((string)($_SESSION['csrf_token'] ?? ''), $csrf)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
$answer['message'] = 'Invalid request.';
|
$answer['message'] = 'Invalid request.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
@@ -94,6 +96,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
|
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
|
||||||
|
|
||||||
|
// ── Step 2b: Throttle — each attempt sends an SMTP test email ────────────────
|
||||||
|
rate_limit_guard($pdo1, [
|
||||||
|
['onboarding_ip', rate_limit_client_ip(), 20, 900],
|
||||||
|
['onboarding_user', (string)$user_id, 10, 900],
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// ── Step 3: Sanitise input ────────────────────────────────────────────────
|
// ── Step 3: Sanitise input ────────────────────────────────────────────────
|
||||||
@@ -147,7 +155,7 @@ try {
|
|||||||
// ── Step 7: Encrypt SMTP password ────────────────────────────────────
|
// ── Step 7: Encrypt SMTP password ────────────────────────────────────
|
||||||
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
|
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
|
||||||
// so the stored password can be decrypted by the mailer module.
|
// so the stored password can be decrypted by the mailer module.
|
||||||
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
|
$encrypted_pass = secret_encrypt($smtp_password, $pinkey);
|
||||||
|
|
||||||
// Assemble a temporary SMTP config for the test send (step 8)
|
// Assemble a temporary SMTP config for the test send (step 8)
|
||||||
$smtp_config = [
|
$smtp_config = [
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ require_once '../../../config.php';
|
|||||||
require_once '../../../dbconn.php';
|
require_once '../../../dbconn.php';
|
||||||
require_once '../../../assets/utils/db_helpers.php';
|
require_once '../../../assets/utils/db_helpers.php';
|
||||||
require_once '../../../assets/utils/classes/PasswordManager.php';
|
require_once '../../../assets/utils/classes/PasswordManager.php';
|
||||||
|
require_once '../../../assets/utils/rate_limit.php';
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ $answer = ['success' => 0, 'message' => ''];
|
|||||||
// stored in session. This prevents cross-site request forgery on the register form.
|
// stored in session. This prevents cross-site request forgery on the register form.
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
|
if (empty($csrf) || !hash_equals((string)($_SESSION['csrf_token'] ?? ''), $csrf)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
$answer['message'] = 'Invalid request.';
|
$answer['message'] = 'Invalid request.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
@@ -67,6 +68,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
|
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
|
||||||
|
|
||||||
|
// ── Step 1b: Throttle — every registration sends a verification email ────────
|
||||||
|
rate_limit_guard($pdo1, [
|
||||||
|
['register_ip', rate_limit_client_ip(), 10, 3600],
|
||||||
|
['register_email', strtolower(trim((string)($data['email'] ?? ''))), 3, 3600],
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// ── Step 2: Sanitise input ────────────────────────────────────────────────
|
// ── Step 2: Sanitise input ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,42 +4,31 @@
|
|||||||
*
|
*
|
||||||
* Called by: login page AJAX "Resend OTP" button on the OTP input screen.
|
* Called by: login page AJAX "Resend OTP" button on the OTP input screen.
|
||||||
* Input: All data sourced from $_SESSION (written by login_otp.php).
|
* Input: All data sourced from $_SESSION (written by login_otp.php).
|
||||||
* No new user input is accepted — credentials are re-read from session
|
* No new user input is accepted.
|
||||||
* to avoid re-exposing the password in a second HTTP request.
|
|
||||||
*
|
*
|
||||||
* This endpoint regenerates a fresh TOTP and resends the OTP email without
|
* This endpoint regenerates a fresh TOTP and resends the OTP email without
|
||||||
* requiring the user to re-enter their username and password. It is only
|
* requiring the user to re-enter their username and password. It is only
|
||||||
* reachable after login_otp.php has successfully validated credentials and
|
* reachable while a pending login exists: login_otp.php verified the password
|
||||||
* written the login session state.
|
* less than LOGIN_PENDING_SECONDS ago (password_verified_at). The password
|
||||||
|
* itself is never kept in the session, so it is not re-checked here.
|
||||||
*
|
*
|
||||||
* Full flow:
|
* Full flow:
|
||||||
* 1. Reload username, password, and user_id from session.
|
* 1. Require a fresh pending login; otherwise HTTP 401 (code "login_restart").
|
||||||
* 2. Fetch the full user row (need the password hash to regenerate OTP
|
* 2. Throttle: per client IP, per user, and at most LOGIN_OTP_MAX_RESENDS
|
||||||
* and the email address to resend to).
|
* resends per pending login (HTTP 429).
|
||||||
* 3. Re-verify the stored password against the session-stored hash.
|
* 3. Fetch the user row (password hash for the OTP, email to send to).
|
||||||
* This is a safety re-check — the session could theoretically have been
|
* 4. Generate a fresh 6-digit TOTP (new timestamp → new OTP) and a random
|
||||||
* tampered with between login_otp.php and this call.
|
* 6-letter reference number.
|
||||||
* 4. On password mismatch → clear cookies, return "Incorrect Password".
|
* 5. Send the OTP email with the same SMTP choice as login_otp.php: the
|
||||||
* 5. On success:
|
* default company's SMTP when configured, otherwise the system $SMTP.
|
||||||
* a. Generate a fresh 6-digit TOTP (new timestamp → new OTP).
|
* 6. Write the new OTP state (the wrong-attempt counter restarts).
|
||||||
* b. Generate a new 6-letter reference number.
|
* 7. Return { success: 1, message: "Login Complete!" }.
|
||||||
* c. Send the OTP email via system SMTP ($SMTP from config.php).
|
|
||||||
* Note: uses system-level SMTP unconditionally (unlike login_otp.php
|
|
||||||
* which tries the company SMTP first). The if(true) wrapper is a
|
|
||||||
* placeholder left from the original — email always sends.
|
|
||||||
* d. Clear session and repopulate with new OTP state.
|
|
||||||
* 6. Return { success: 1, message: "Login Complete!" }.
|
|
||||||
*
|
*
|
||||||
* Session keys read:
|
* Session keys read:
|
||||||
* login_data['username'], login_data['password'], login_user_id
|
* login_user_id, password_verified_at, otp_resends
|
||||||
*
|
*
|
||||||
* Session keys overwritten:
|
* Session keys overwritten:
|
||||||
* login_data, otp, otpTime, reference, user_email, login_user_id
|
* otp, otpTime, reference, user_email, otp_attempts, otp_resends
|
||||||
* (same keys as login_otp.php — login_confirm.php reads the same structure)
|
|
||||||
*
|
|
||||||
* Response JSON:
|
|
||||||
* On success: { "success": 1, "message": "Login Complete!" }
|
|
||||||
* On failure: { "message": "Incorrect Password" }
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once '../../../session.php';
|
require_once '../../../session.php';
|
||||||
@@ -47,73 +36,65 @@ require_once '../../../config.php';
|
|||||||
require_once '../../../preset.php';
|
require_once '../../../preset.php';
|
||||||
define('UNAUTHENTICATED_ROUTE', true);
|
define('UNAUTHENTICATED_ROUTE', true);
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
|
require_once '../../../assets/utils/rate_limit.php';
|
||||||
|
require_once '../login_helpers.php';
|
||||||
|
|
||||||
// ── Step 1: Reload credentials from session ───────────────────────────────────
|
// ── Step 1: Require a pending login ───────────────────────────────────────────
|
||||||
// These were stored by login_otp.php so the user doesn't have to retype them.
|
if (!login_pending_valid()) {
|
||||||
$data["username"] = $_SESSION["login_data"]['username'];
|
$_SESSION = [];
|
||||||
$data["password"] = $_SESSION["login_data"]['password'];
|
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"];
|
$user_id = (int)$_SESSION["login_user_id"];
|
||||||
|
|
||||||
// ── Step 2: Fetch user record ─────────────────────────────────────────────────
|
// ── Step 2: Throttle resends ──────────────────────────────────────────────────
|
||||||
|
if ((int)($_SESSION['otp_resends'] ?? 0) >= LOGIN_OTP_MAX_RESENDS) {
|
||||||
|
rate_limit_reject();
|
||||||
|
}
|
||||||
|
rate_limit_guard($pdo1, [
|
||||||
|
['otp_resend_ip', rate_limit_client_ip(), 10, 900],
|
||||||
|
['otp_resend_user', (string)$user_id, 5, 900],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Step 3: Fetch user record ─────────────────────────────────────────────────
|
||||||
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
|
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
|
||||||
$sth->execute([":user_id" => $user_id]);
|
$sth->execute([":user_id" => $user_id]);
|
||||||
$temp = $sth->fetch(PDO::FETCH_ASSOC);
|
$temp = $sth->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$temp) {
|
||||||
|
$_SESSION = [];
|
||||||
|
login_fail(401, 'Your sign-in has expired. Please enter your username and password again.', ['code' => 'login_restart']);
|
||||||
|
}
|
||||||
|
|
||||||
$user_email = $temp["email"];
|
$user_email = $temp["email"];
|
||||||
|
|
||||||
// ── Step 3–4: Re-verify password ─────────────────────────────────────────────
|
// ── Step 4: Generate fresh 6-digit TOTP + random reference ────────────────────
|
||||||
// Safety check — ensures the session hasn't been tampered with between
|
|
||||||
// login_otp.php and this resend call.
|
|
||||||
if (password_verify(trim($data["password"]), $temp["password"])) {
|
|
||||||
|
|
||||||
// ── Step 5a: Generate fresh 6-digit TOTP ──────────────────────────────────
|
|
||||||
// Same HMAC-SHA1 algorithm as login_otp.php and login_confirm.php.
|
// Same HMAC-SHA1 algorithm as login_otp.php and login_confirm.php.
|
||||||
// A new $otpTime is captured so the OTP window resets from this moment.
|
// A new $otpTime is captured so the OTP window resets from this moment.
|
||||||
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
|
$otpTime = time();
|
||||||
|
$otp = login_generate_otp($temp["password"], $otpTime);
|
||||||
|
$reference_number = login_random_reference();
|
||||||
|
|
||||||
global $otpTime;
|
// ── Step 5: Send OTP email ────────────────────────────────────────────────────
|
||||||
|
// Company SMTP of the user's default company when configured, otherwise the
|
||||||
$otpTime = time(); // new timestamp — extends the 5-minute validity window
|
// system-level $SMTP from config.php.
|
||||||
|
$smtp_config = $SMTP;
|
||||||
$counter = floor($otpTime / $time_step);
|
$default_company = (int)($temp["default_company"] ?? 0);
|
||||||
$data = pack("NN", 0, $counter);
|
if ($default_company > 0) {
|
||||||
$hash = hash_hmac('sha1', $data, $sercet_key, true);
|
$sth = $pdo1->prepare("SELECT * FROM company_smtp WHERE company_id = :cid LIMIT 1");
|
||||||
$offset = ord(substr($hash, -1)) & 0x0F;
|
$sth->execute([":cid" => $default_company]);
|
||||||
$value = unpack("N", substr($hash, $offset, 4));
|
$smtp_row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||||
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
|
if (!empty($smtp_row)) {
|
||||||
|
$smtp_config = $smtp_row;
|
||||||
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5b: Generate 6-letter reference number ───────────────────────────
|
|
||||||
// Converts a second TOTP (derived from the first OTP as the key) to a
|
|
||||||
// base-26 uppercase letter string shown on the OTP input screen.
|
|
||||||
function numberToLetters($num) {
|
|
||||||
$result = '';
|
|
||||||
while ($num > 0) {
|
|
||||||
$mod = ($num - 1) % 26;
|
|
||||||
$result = chr(65 + $mod) . $result;
|
|
||||||
$num = intval(($num - $mod) / 26);
|
|
||||||
}
|
|
||||||
return str_pad($result, 6, 'A', STR_PAD_LEFT);
|
|
||||||
}
|
|
||||||
|
|
||||||
$otp = generateOTP($temp["password"]);
|
|
||||||
$reference_number = numberToLetters(generateOTP($otp));
|
|
||||||
|
|
||||||
// ── Step 5c: Send OTP email ───────────────────────────────────────────────
|
|
||||||
// Uses the system-level $SMTP config from config.php.
|
|
||||||
// The if(true) wrapper is a no-op placeholder from the original code —
|
|
||||||
// the email block always executes.
|
|
||||||
require "../../../assets/utils/module/mailer.php";
|
require "../../../assets/utils/module/mailer.php";
|
||||||
|
|
||||||
if (true) {
|
$mailer = new mailer(["pdo1" => $pdo1, "pdo2" => $pdo2]);
|
||||||
|
|
||||||
$mailer = new mailer(["pdo1" => $pdo1]);
|
|
||||||
|
|
||||||
$mailer->send_email([
|
$mailer->send_email([
|
||||||
"company_id" => 0,
|
"company_id" => $smtp_config === $SMTP ? 0 : $default_company,
|
||||||
"smtp" => $SMTP,
|
"smtp" => $smtp_config,
|
||||||
"subject" => "One Time Password (OTP) For reference number " . $reference_number,
|
"subject" => "One Time Password (OTP) For reference number " . $reference_number,
|
||||||
"message" => implode("\n", [
|
"message" => implode("\n", [
|
||||||
"Dear WMS user,",
|
"Dear WMS user,",
|
||||||
@@ -133,34 +114,18 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
"to" => $user_email,
|
"to" => $user_email,
|
||||||
"key" => $pinkey,
|
"key" => $pinkey,
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 5d: Reset session with new OTP state ─────────────────────────────
|
// ── Step 6: Write the new OTP state ───────────────────────────────────────────
|
||||||
// Full session is cleared before repopulating to avoid stale state
|
// The pending-login keys (login_data, login_user_id, password_verified_at) stay
|
||||||
// from the previous OTP attempt leaking into this one.
|
// as they are; only the OTP state is replaced.
|
||||||
$_SESSION = [];
|
|
||||||
|
|
||||||
$_SESSION["login_data"] = $data;
|
|
||||||
$_SESSION["otp"] = $otp;
|
$_SESSION["otp"] = $otp;
|
||||||
$_SESSION["otpTime"] = $otpTime; // new timestamp — login_confirm.php uses this
|
$_SESSION["otpTime"] = $otpTime; // new timestamp — login_confirm.php uses this
|
||||||
$_SESSION["reference"] = $reference_number;
|
$_SESSION["reference"] = $reference_number;
|
||||||
$_SESSION["user_email"] = $user_email;
|
$_SESSION["user_email"] = $user_email;
|
||||||
$_SESSION["login_user_id"] = $user_id;
|
$_SESSION["otp_attempts"] = 0;
|
||||||
|
$_SESSION["otp_resends"] = (int)($_SESSION["otp_resends"] ?? 0) + 1;
|
||||||
|
|
||||||
// ── Step 6: Respond ───────────────────────────────────────────────────────
|
// ── Step 7: Respond ───────────────────────────────────────────────────────────
|
||||||
$answer["success"] = 1;
|
$answer["success"] = 1;
|
||||||
$answer["message"] = "Login Complete!";
|
$answer["message"] = "Login Complete!";
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
|
||||||
} else {
|
|
||||||
|
|
||||||
// ── Password mismatch — clear cookies and reject ──────────────────────────
|
|
||||||
$answer["message"] = "Incorrect Password";
|
|
||||||
setcookie("u", "", time() - 1, "/");
|
|
||||||
setcookie("h1", "", time() - 1, "/");
|
|
||||||
setcookie("h2", "", time() - 1, "/");
|
|
||||||
exit(json_encode($answer));
|
|
||||||
}
|
|
||||||
|
|
||||||
$answer["success"] = 1;
|
|
||||||
exit(json_encode($answer));
|
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* request_reset_otp.php — login page "Forgot password?" step 1.
|
||||||
|
*
|
||||||
|
* Unauthenticated. Answers the same way whether or not the username/email
|
||||||
|
* matches an account (see PasswordResetManager::handleRequestOtpPublic), and is
|
||||||
|
* throttled per client IP and per identifier because every hit can send email.
|
||||||
|
*/
|
||||||
|
|
||||||
require_once '../../../session.php';
|
require_once '../../../session.php';
|
||||||
define('UNAUTHENTICATED_ROUTE', true);
|
define('UNAUTHENTICATED_ROUTE', true);
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
|
require_once '../../../assets/utils/rate_limit.php';
|
||||||
|
|
||||||
// Resolve user by username or email
|
// Resolve user by username or email
|
||||||
$identifier = strtolower(trim($data['identifier'] ?? ''));
|
$identifier = strtolower(trim($data['identifier'] ?? ''));
|
||||||
@@ -13,22 +21,21 @@ if (!$identifier) {
|
|||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rate_limit_guard($pdo1, [
|
||||||
|
['reset_req_ip', rate_limit_client_ip(), 10, 900],
|
||||||
|
['reset_req_id', $identifier, 3, 900],
|
||||||
|
]);
|
||||||
|
|
||||||
$sth = $pdo1->prepare(
|
$sth = $pdo1->prepare(
|
||||||
"SELECT user_id, default_company FROM user WHERE username = :i OR email = :i LIMIT 1"
|
"SELECT user_id, default_company FROM user WHERE username = :i OR email = :i LIMIT 1"
|
||||||
);
|
);
|
||||||
$sth->execute([':i' => $identifier]);
|
$sth->execute([':i' => $identifier]);
|
||||||
$user = $sth->fetch(PDO::FETCH_ASSOC);
|
$user = $sth->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
if (!$user) {
|
$user_id = $user ? (int)$user['user_id'] : null;
|
||||||
http_response_code(404);
|
$company_id = $user ? (int)($user['default_company'] ?? 0) : 0;
|
||||||
$answer['message'] = 'No account found with that username or email.';
|
|
||||||
exit(json_encode($answer));
|
|
||||||
}
|
|
||||||
|
|
||||||
$user_id = (int)$user['user_id'];
|
|
||||||
$company_id = (int)($user['default_company'] ?? 0);
|
|
||||||
|
|
||||||
require_once '../../../assets/utils/classes/PasswordResetManager.php';
|
require_once '../../../assets/utils/classes/PasswordResetManager.php';
|
||||||
|
|
||||||
$manager = new PasswordResetManager($pdo1, $pdo2, $include_url, $SMTP, $pinkey);
|
$manager = new PasswordResetManager($pdo1, $pdo2, $include_url, $SMTP, $pinkey);
|
||||||
$manager->handleRequestOtp($user_id, $company_id);
|
$manager->handleRequestOtpPublic($user_id, $company_id);
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* reset_password_otp.php — login page "Forgot password?" step 2.
|
||||||
|
*
|
||||||
|
* Unauthenticated. Needs the reset state written by request_reset_otp.php in
|
||||||
|
* this session. For a username that matched no account that state is a decoy
|
||||||
|
* (reset_user_id 0) which always answers "Incorrect OTP", so this step does not
|
||||||
|
* reveal whether the account exists either.
|
||||||
|
*/
|
||||||
|
|
||||||
require_once '../../../session.php';
|
require_once '../../../session.php';
|
||||||
define('UNAUTHENTICATED_ROUTE', true);
|
define('UNAUTHENTICATED_ROUTE', true);
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
|
require_once '../../../assets/utils/rate_limit.php';
|
||||||
|
|
||||||
if (empty($_SESSION['reset_user_id'])) {
|
rate_limit_guard($pdo1, [
|
||||||
|
['reset_confirm_ip', rate_limit_client_ip(), 30, 900],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isset($_SESSION['reset_user_id'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
$answer['message'] = 'No active reset request. Please request a new OTP.';
|
$answer['message'] = 'No active reset request. Please request a new OTP.';
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* login_helpers.php — shared pieces of the 2-step login flow
|
||||||
|
* (login_otp.php → login_confirm.php, with request_new_otp.php for resends).
|
||||||
|
*
|
||||||
|
* Pending-login session state (written by login_otp.php once the password has
|
||||||
|
* been verified; the password itself is never kept in the session):
|
||||||
|
* login_data['username'] — normalised username/email the user typed
|
||||||
|
* login_user_id — resolved user id
|
||||||
|
* password_verified_at — Unix time the password was checked
|
||||||
|
* otp_attempts — wrong OTP entries for the current code
|
||||||
|
* otp_resends — OTP resends for this pending login
|
||||||
|
*/
|
||||||
|
|
||||||
|
// One answer for unknown username, wrong password and locked account, so the
|
||||||
|
// login form cannot be used to find out which accounts exist.
|
||||||
|
const LOGIN_GENERIC_FAILURE = 'Incorrect username or password, or the account is temporarily locked.';
|
||||||
|
|
||||||
|
// A verified password is good for this long before the user must type it again.
|
||||||
|
const LOGIN_PENDING_SECONDS = 600;
|
||||||
|
|
||||||
|
// Wrong OTP entries allowed per issued code; the next one ends the pending login.
|
||||||
|
const LOGIN_OTP_MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
// OTP resends allowed per pending login.
|
||||||
|
const LOGIN_OTP_MAX_RESENDS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 6-digit TOTP (HMAC-SHA1, 3-minute step) keyed by the user's password hash, so
|
||||||
|
* a password change invalidates it. Same algorithm db_auth.php re-derives on
|
||||||
|
* every request.
|
||||||
|
*/
|
||||||
|
function login_generate_otp(string $secret_key, int $otp_time, int $time_step = 180, int $length = 6): string {
|
||||||
|
$counter = floor($otp_time / $time_step);
|
||||||
|
$data = pack("NN", 0, $counter);
|
||||||
|
$hash = hash_hmac('sha1', $data, $secret_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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random 6-letter reference shown on the OTP screen and in the email. It used to
|
||||||
|
* be derived from the OTP, which let anyone who saw the reference recover the
|
||||||
|
* OTP offline by trying all 10^6 codes; a random value carries no information.
|
||||||
|
*/
|
||||||
|
function login_random_reference(): string {
|
||||||
|
$ref = '';
|
||||||
|
for ($i = 0; $i < 6; $i++) {
|
||||||
|
$ref .= chr(65 + random_int(0, 25));
|
||||||
|
}
|
||||||
|
return $ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the session holds a pending login whose password check is still fresh. */
|
||||||
|
function login_pending_valid(): bool {
|
||||||
|
return !empty($_SESSION['login_user_id'])
|
||||||
|
&& !empty($_SESSION['password_verified_at'])
|
||||||
|
&& (time() - (int)$_SESSION['password_verified_at']) <= LOGIN_PENDING_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer with an HTTP status and a JSON message, then stop. */
|
||||||
|
function login_fail(int $status, string $message, array $extra = []): void {
|
||||||
|
http_response_code($status);
|
||||||
|
exit(json_encode(array_merge(['success' => 0, 'message' => $message], $extra)));
|
||||||
|
}
|
||||||
@@ -11,12 +11,12 @@
|
|||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
}
|
}
|
||||||
|
|
||||||
require '../include_header.php';
|
require __DIR__ . '/include_login_header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/zxcvbn@4.4.2/dist/zxcvbn.js"></script>
|
<script src="<?php echo $server_url?>assets/vendor/zxcvbn/4.4.2/zxcvbn.js"></script>
|
||||||
|
|
||||||
<div class="container d-flex align-items-center justify-content-center min-vh-100">
|
<div class="container d-flex align-items-center justify-content-center min-vh-100">
|
||||||
<div class="card" style="max-width:420px; width:100%;">
|
<div class="card" style="max-width:420px; width:100%;">
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
<div id="step_reset" class="d-none">
|
<div id="step_reset" class="d-none">
|
||||||
<div class="alert alert-info small py-2 mb-4">
|
<div class="alert alert-info small py-2 mb-4">
|
||||||
<i class="ti ti-mail me-1"></i>
|
<i class="ti ti-mail me-1"></i>
|
||||||
OTP sent to <strong id="masked_email"></strong>
|
<span id="request_message"></span>
|
||||||
— reference <strong id="ref_code"></strong>
|
Reference <strong id="ref_code"></strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">OTP <span class="text-danger">*</span></label>
|
<label class="form-label">OTP <span class="text-danger">*</span></label>
|
||||||
@@ -163,8 +163,10 @@
|
|||||||
autoPrepare: false,
|
autoPrepare: false,
|
||||||
data: { json: JSON.stringify({ action: 'read', identifier: identifier }) },
|
data: { json: JSON.stringify({ action: 'read', identifier: identifier }) },
|
||||||
onSuccess: function (r) {
|
onSuccess: function (r) {
|
||||||
$('#masked_email').text(r.masked_email);
|
// The server answers the same way whether or not the account exists,
|
||||||
$('#ref_code').text(r.reference);
|
// so there is no masked email to show — only its generic message.
|
||||||
|
$('#request_message').text(r.message || '');
|
||||||
|
$('#ref_code').text(r.reference || '');
|
||||||
$('#step_request').addClass('d-none');
|
$('#step_request').addClass('d-none');
|
||||||
$('#step_reset').removeClass('d-none');
|
$('#step_reset').removeClass('d-none');
|
||||||
$('#subtitle').text('Enter the OTP from your email and choose a new password.');
|
$('#subtitle').text('Enter the OTP from your email and choose a new password.');
|
||||||
|
|||||||
+18
-10
@@ -2,7 +2,7 @@
|
|||||||
require '../session.php';
|
require '../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require_once '../assets/utils/otp_policy.php';
|
require_once '../assets/utils/otp_policy.php';
|
||||||
require '../include_header.php';
|
require __DIR__ . '/include_login_header.php';
|
||||||
// successful login — redirect based on app_access
|
// successful login — redirect based on app_access
|
||||||
if(!empty($_SESSION["login_status"])){
|
if(!empty($_SESSION["login_status"])){
|
||||||
$redirect = ($_SESSION['login_app_access'] ?? 'wms') === 'accounting'
|
$redirect = ($_SESSION['login_app_access'] ?? 'wms') === 'accounting'
|
||||||
@@ -33,13 +33,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="needs-validation mt-3" novalidate id="login-form">
|
<form class="needs-validation mt-3" novalidate id="login-form">
|
||||||
<?php if (!otp_required()): ?>
|
<!-- Whether email OTP is on is server configuration and is not shown to
|
||||||
<!-- OTP_REQUIRED is not true in config.php (the default): a password-only sign-in must never be invisible -->
|
anonymous visitors; password-only sign-ins are logged as OTP_BYPASSED
|
||||||
<div class="alert alert-warning small py-2 mb-3" title="OTP_REQUIRED is not true in config.php">
|
(assets/utils/otp_policy.php). -->
|
||||||
<i class="ti ti-alert-triangle me-1"></i>
|
|
||||||
Email OTP is off — sign-in is password only.
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<!-- first step login [OTP] -->
|
<!-- first step login [OTP] -->
|
||||||
<?php if(!isset($_SESSION['login_data'])){?>
|
<?php if(!isset($_SESSION['login_data'])){?>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -144,6 +140,16 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// The server ended the pending sign-in (too many wrong OTPs, or the verified
|
||||||
|
// password is too old): show why, then return to the username/password step.
|
||||||
|
function restart_login_on(xhr) {
|
||||||
|
if (xhr?.responseJSON?.code !== 'login_restart') return;
|
||||||
|
bootbox.hideAll();
|
||||||
|
bootbox.alert(escape_html(xhr.responseJSON.message || 'Please sign in again.'), function() {
|
||||||
|
window.location.href = "<?php echo $server_url?>login/index.php";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// reqquest new otp function
|
// reqquest new otp function
|
||||||
function request_new_otp() {
|
function request_new_otp() {
|
||||||
|
|
||||||
@@ -156,7 +162,8 @@
|
|||||||
|
|
||||||
window.location.href = "<?php echo $server_url?>index.php";
|
window.location.href = "<?php echo $server_url?>index.php";
|
||||||
|
|
||||||
}
|
},
|
||||||
|
onError: restart_login_on
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -194,7 +201,8 @@
|
|||||||
|
|
||||||
window.location.href = "index.php";
|
window.location.href = "index.php";
|
||||||
|
|
||||||
}
|
},
|
||||||
|
onError: restart_login_on
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// Profile page "Forgot your current password?" — signed-in users only.
|
||||||
|
// (This used to declare UNAUTHENTICATED_ROUTE, which let anonymous callers in
|
||||||
|
// with no $user_id; the login page has its own endpoint in login/api/engine/.)
|
||||||
require_once __DIR__ . '/../../../session.php';
|
require_once __DIR__ . '/../../../session.php';
|
||||||
define('UNAUTHENTICATED_ROUTE', true);
|
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
|
require_once '../../../assets/utils/rate_limit.php';
|
||||||
require_once '../../../assets/utils/classes/PasswordResetManager.php';
|
require_once '../../../assets/utils/classes/PasswordResetManager.php';
|
||||||
|
|
||||||
|
// Every hit sends an email.
|
||||||
|
rate_limit_guard($pdo1, [
|
||||||
|
['reset_req_user', (string)$user_id, 3, 900],
|
||||||
|
]);
|
||||||
|
|
||||||
$prm = new PasswordResetManager($pdo1, $pdo2, $include_url, $SMTP, $pinkey);
|
$prm = new PasswordResetManager($pdo1, $pdo2, $include_url, $SMTP, $pinkey);
|
||||||
$prm->handleRequestOtp($user_id, $company_id);
|
$prm->handleRequestOtp($user_id, $company_id);
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// Profile page "Forgot your current password?" step 2 — signed-in users only.
|
||||||
require_once __DIR__ . '/../../../session.php';
|
require_once __DIR__ . '/../../../session.php';
|
||||||
define('UNAUTHENTICATED_ROUTE', true);
|
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
|
require_once '../../../assets/utils/rate_limit.php';
|
||||||
require_once '../../../assets/utils/classes/PasswordResetManager.php';
|
require_once '../../../assets/utils/classes/PasswordResetManager.php';
|
||||||
|
|
||||||
|
rate_limit_guard($pdo1, [
|
||||||
|
['reset_confirm_user', (string)$user_id, 15, 900],
|
||||||
|
]);
|
||||||
|
|
||||||
$prm = new PasswordResetManager($pdo1, $pdo2, $include_url, $SMTP, $pinkey);
|
$prm = new PasswordResetManager($pdo1, $pdo2, $include_url, $SMTP, $pinkey);
|
||||||
$prm->handleConfirmReset($user_id, $data);
|
$prm->handleConfirmReset($user_id, $data);
|
||||||
Reference in New Issue
Block a user