modify classed and comments
This commit is contained in:
@@ -3,21 +3,34 @@
|
||||
/**
|
||||
* PasswordResetManager
|
||||
*
|
||||
* Handles the full OTP-based password reset flow.
|
||||
* Handles the full OTP-based password reset flow for the WMS application.
|
||||
* Reusable across:
|
||||
* - Profile page: "Forgot your current password?" (authenticated user)
|
||||
* - Login page: "Forgot password?" (unauthenticated user)
|
||||
* - Login page: "Forgot password?" (unauthenticated user — user_id resolved by caller)
|
||||
*
|
||||
* Flow:
|
||||
* 1. requestOtp($user_id) — generate OTP, email it, store in session
|
||||
* 2. confirmReset($user_id, ...) — verify OTP, call PasswordManager::forceSet()
|
||||
* Reset flow:
|
||||
* 1. requestOtp($user_id, $company_id) — generate TOTP, email it, store in session.
|
||||
* 2. confirmReset($user_id, $otp, ...) — verify OTP, call PasswordManager::forceSet().
|
||||
*
|
||||
* HTTP handler methods for thin endpoint wrappers:
|
||||
* handleRequestOtp($user_id)
|
||||
* 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.
|
||||
* A human-readable reference number (6 uppercase letters) is also generated and emailed
|
||||
* so the user can confirm they received the correct OTP request.
|
||||
*
|
||||
* HTTP handler methods for thin AJAX endpoint wrappers:
|
||||
* handleRequestOtp($user_id, $company_id)
|
||||
* handleConfirmReset($user_id, $data)
|
||||
*
|
||||
* Session keys used (prefixed 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
|
||||
*
|
||||
* Security:
|
||||
* - 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().
|
||||
* - reset_user_id in session is verified against $user_id to prevent cross-user OTP reuse.
|
||||
* - Session is fully destroyed on successful reset, forcing re-authentication.
|
||||
* - All DB queries use PDO prepared statements with bound parameters.
|
||||
* - AJAX handler methods output JSON via json_encode (XSS-safe).
|
||||
*/
|
||||
class PasswordResetManager {
|
||||
|
||||
@@ -27,15 +40,17 @@ class PasswordResetManager {
|
||||
private $SMTP;
|
||||
private $pinkey;
|
||||
|
||||
/** OTP validity window in minutes — matches login OTP */
|
||||
/** OTP validity window in minutes — matches the login OTP window. */
|
||||
const OTP_EXPIRY_MINUTES = 5;
|
||||
|
||||
/**
|
||||
* @param PDO $pdo1 Main DB (user table)
|
||||
* @param PDO $pdo2 Company DB (smtp_setting table)
|
||||
* @param string $include_url Absolute server path to app root (for requires)
|
||||
* @param array $SMTP System default SMTP config from config.php
|
||||
* @param string $pinkey Encryption key from config.php
|
||||
* @param PDO $pdo1 PDO connection to the wms database (user table).
|
||||
* @param PDO $pdo2 PDO connection to the company database (smtp_setting table).
|
||||
* @param string $include_url Absolute server path to the app root (for require_once paths).
|
||||
* Example: '/var/www/html/wms'
|
||||
* @param array $SMTP System default SMTP configuration array from config.php.
|
||||
* Used as fallback when the company has no custom SMTP.
|
||||
* @param string $pinkey Encryption key from config.php, passed to the mailer.
|
||||
*/
|
||||
public function __construct($pdo1, $pdo2, string $include_url, array $SMTP, string $pinkey) {
|
||||
$this->pdo1 = $pdo1;
|
||||
@@ -46,22 +61,29 @@ class PasswordResetManager {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: request OTP
|
||||
// Core public API
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate OTP, send it to the user's registered email, store in session.
|
||||
* Generate a TOTP, send it to the user's registered email, and store it in session.
|
||||
*
|
||||
* @param int $user_id From session (profile) or looked-up by email/username (login)
|
||||
* @param int $company_id For SMTP fallback lookup
|
||||
* @return array ['masked_email' => string, 'reference' => string]
|
||||
* The OTP is derived from the user's current password hash so it is unique per user
|
||||
* and automatically invalidated if the password is changed by any other means.
|
||||
* A reference number (6 uppercase letters) is included in the email so the user
|
||||
* can verify the request is legitimate.
|
||||
*
|
||||
* @throws \RuntimeException if user not found or email missing
|
||||
* @throws \Exception if mailer fails (mailer calls exit internally)
|
||||
* Call this from the "request OTP" endpoint. The user_id must be resolved by the
|
||||
* caller (from $_SESSION for authenticated flows, or from a username/email lookup
|
||||
* for unauthenticated login-page flows).
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id whose password is being reset.
|
||||
* @param int $company_id Used to look up company SMTP settings (0 = use system default).
|
||||
* @return array Keys: masked_email (string), reference (string 6-letter code).
|
||||
* @throws \RuntimeException If user not found, email missing, or mailer fails.
|
||||
*/
|
||||
public function requestOtp(int $user_id, int $company_id = 0): array {
|
||||
|
||||
// ── Fetch user ────────────────────────────────────────────
|
||||
// Fetch user — need email (to send to) and password hash (for OTP derivation)
|
||||
$sth = $this->pdo1->prepare(
|
||||
'SELECT user_id, email, password FROM user WHERE user_id = :id LIMIT 1'
|
||||
);
|
||||
@@ -72,12 +94,12 @@ class PasswordResetManager {
|
||||
throw new \RuntimeException('No email address found for this account.');
|
||||
}
|
||||
|
||||
// ── Generate OTP ──────────────────────────────────────────
|
||||
// Generate 6-digit TOTP and a human-readable 6-letter reference number
|
||||
$otp_time = time();
|
||||
$otp = $this->generateOTP($user['password'], $otp_time);
|
||||
$reference_number = $this->numberToLetters((int) $this->generateOTP($otp, $otp_time));
|
||||
|
||||
// ── Send email ────────────────────────────────────────────
|
||||
// Send via the mailer module (uses company SMTP or falls back to system default)
|
||||
require_once $this->include_url . '/assets/utils/module/mailer.php';
|
||||
|
||||
$mailer = new mailer(['pdo1' => $this->pdo1, 'pdo2' => $this->pdo2]);
|
||||
@@ -97,81 +119,94 @@ class PasswordResetManager {
|
||||
'key' => $this->pinkey,
|
||||
]);
|
||||
|
||||
// ── Store in session ──────────────────────────────────────
|
||||
// Persist in session so confirmReset() can verify against it
|
||||
$_SESSION['reset_otp'] = $otp;
|
||||
$_SESSION['reset_otp_time'] = $otp_time;
|
||||
$_SESSION['reset_reference'] = $reference_number;
|
||||
$_SESSION['reset_user_id'] = $user_id;
|
||||
|
||||
// ── Return masked email for UI display ────────────────────
|
||||
return [
|
||||
'masked_email' => $this->maskEmail($user['email']),
|
||||
'reference' => $reference_number,
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: confirm reset
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify OTP then force-set new password via PasswordManager.
|
||||
* Verify the OTP and force-set a new password via PasswordManager.
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param string $otp_input
|
||||
* @param string $new_password
|
||||
* @param string $confirm_password
|
||||
* Validates:
|
||||
* - Active session with a stored OTP and timestamp.
|
||||
* - Session reset_user_id matches the $user_id being reset (prevents cross-user reuse).
|
||||
* - OTP is within the OTP_EXPIRY_MINUTES window.
|
||||
* - Submitted OTP matches the stored value exactly.
|
||||
*
|
||||
* @throws \InvalidArgumentException OTP invalid/expired, passwords weak/mismatch
|
||||
* @throws \RuntimeException DB or session state error
|
||||
* On success:
|
||||
* - Delegates to PasswordManager::forceSet() for strength enforcement and hashing.
|
||||
* - Clears reset session keys and destroys the full session (forces re-login).
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id being reset.
|
||||
* @param string $otp_input The OTP submitted by the user.
|
||||
* @param string $new_password The desired new password.
|
||||
* @param string $confirm_password Must match $new_password exactly.
|
||||
* @throws \InvalidArgumentException OTP missing, expired, incorrect, or passwords invalid.
|
||||
* @throws \RuntimeException Session integrity error or DB failure.
|
||||
*/
|
||||
public function confirmReset(int $user_id, string $otp_input, string $new_password, string $confirm_password): void {
|
||||
|
||||
// ── Validate session state ────────────────────────────────
|
||||
// Validate reset session exists
|
||||
if (empty($_SESSION['reset_otp']) || empty($_SESSION['reset_otp_time'])) {
|
||||
throw new \InvalidArgumentException('No active reset request. Please request a new OTP.');
|
||||
}
|
||||
|
||||
// ── Verify ownership ──────────────────────────────────────
|
||||
// Verify this OTP belongs to the user making the request (prevents session swap attacks)
|
||||
if ((int)$_SESSION['reset_user_id'] !== $user_id) {
|
||||
throw new \RuntimeException('Invalid reset request.');
|
||||
}
|
||||
|
||||
// ── Check expiry ──────────────────────────────────────────
|
||||
// Check OTP has not expired
|
||||
$elapsed_minutes = (time() - (int)$_SESSION['reset_otp_time']) / 60;
|
||||
if ($elapsed_minutes > self::OTP_EXPIRY_MINUTES) {
|
||||
$this->clearSession();
|
||||
throw new \InvalidArgumentException('OTP has expired. Please request a new one.');
|
||||
}
|
||||
|
||||
// ── Verify OTP value ──────────────────────────────────────
|
||||
// Verify OTP value
|
||||
if (trim($otp_input) !== $_SESSION['reset_otp']) {
|
||||
throw new \InvalidArgumentException('Incorrect OTP. Please try again.');
|
||||
}
|
||||
|
||||
// ── Force-set password via PasswordManager ────────────────
|
||||
// Delegate to PasswordManager for strength enforcement and persistence
|
||||
require_once $this->include_url . '/assets/utils/classes/PasswordManager.php';
|
||||
|
||||
$pm = new PasswordManager($this->pdo1, $this->include_url);
|
||||
$pm->forceSet($user_id, $new_password, $confirm_password);
|
||||
|
||||
// ── Clear full session on success ────────────────────────
|
||||
// Destroys the login session so the user must re-authenticate
|
||||
// with their new password. The OTP in db_auth would invalidate
|
||||
// naturally on next request anyway (hash changed), but clearing
|
||||
// here is immediate and explicit.
|
||||
// Clear reset keys and destroy session — user must log in with new password.
|
||||
// The OTP in db_auth would invalidate naturally (hash changed), but
|
||||
// explicit destroy is immediate and leaves no dangling session state.
|
||||
$this->clearSession();
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: HTTP handlers (thin endpoint wrappers call these)
|
||||
// HTTP handlers (thin AJAX endpoint wrappers)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle AJAX request-OTP call and echo JSON.
|
||||
* Endpoint: setting/api/engine/request_reset_otp.php
|
||||
* login/api/engine/request_reset_otp.php
|
||||
* Handle an AJAX request-OTP call and echo a JSON response.
|
||||
*
|
||||
* Called by:
|
||||
* setting/api/engine/request_reset_otp.php (profile page)
|
||||
* login/api/engine/request_reset_otp.php (login page)
|
||||
*
|
||||
* On success: { success: 1, masked_email: "...", reference: "ABCDEF" }
|
||||
* On RuntimeException (user/mail error): HTTP 500, { success: 0, message: "..." }
|
||||
* On other Exception: HTTP 500, generic message (error logged server-side)
|
||||
*
|
||||
* Outputs JSON and calls exit. XSS-safe — all output via json_encode.
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id to send the OTP to.
|
||||
* @param int $company_id Company SMTP scope (0 = use system default).
|
||||
*/
|
||||
public function handleRequestOtp(int $user_id, int $company_id = 0): void {
|
||||
|
||||
@@ -200,11 +235,23 @@ class PasswordResetManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX confirm-reset call and echo JSON.
|
||||
* Endpoint: setting/api/engine/reset_password_otp.php
|
||||
* login/api/engine/reset_password_otp.php
|
||||
* Handle an AJAX confirm-reset call and echo a JSON response.
|
||||
*
|
||||
* Expected $data keys: otp, new_password, confirm_password
|
||||
* Called by:
|
||||
* setting/api/engine/reset_password_otp.php (profile page)
|
||||
* login/api/engine/reset_password_otp.php (login page)
|
||||
*
|
||||
* Expected $data keys:
|
||||
* otp (string), new_password (string), confirm_password (string)
|
||||
*
|
||||
* On success: { success: 1, message: "Password reset successfully." }
|
||||
* On InvalidArgumentException (validation): HTTP 400, { success: 0, message: "..." }
|
||||
* On other Exception: HTTP 500, generic message (error logged server-side)
|
||||
*
|
||||
* Outputs JSON and calls exit. XSS-safe — all output via json_encode.
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id being reset.
|
||||
* @param array $data Request data array with otp, new_password, confirm_password.
|
||||
*/
|
||||
public function handleConfirmReset(int $user_id, array $data): void {
|
||||
|
||||
@@ -236,6 +283,24 @@ class PasswordResetManager {
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a TOTP-style numeric OTP from a secret key and timestamp.
|
||||
*
|
||||
* Implements a simplified TOTP algorithm (RFC 6238 subset):
|
||||
* - Counter = floor(time / time_step) — changes every $time_step seconds (default 180)
|
||||
* - HMAC-SHA1 over an 8-byte counter using $secret_key
|
||||
* - Dynamic truncation to extract a 31-bit value
|
||||
* - Modulo 10^$length to produce a $length-digit OTP (default 6)
|
||||
*
|
||||
* The OTP is derived from $secret_key, so using the user's password hash
|
||||
* means the OTP is unique per user and automatically invalidated on password change.
|
||||
*
|
||||
* @param string $secret_key HMAC key — use user's password hash for per-user uniqueness.
|
||||
* @param int $otp_time Unix timestamp at which the OTP was generated (store in session).
|
||||
* @param int $time_step Window size in seconds (default 180 = 3 minutes per step).
|
||||
* @param int $length Number of digits in the OTP (default 6).
|
||||
* @return string Zero-padded OTP string of $length digits.
|
||||
*/
|
||||
private function generateOTP(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);
|
||||
@@ -246,6 +311,16 @@ class PasswordResetManager {
|
||||
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a positive integer into a base-26 uppercase letter string.
|
||||
*
|
||||
* Used to turn the numeric reference OTP into a human-friendly 6-letter
|
||||
* 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 {
|
||||
$result = '';
|
||||
while ($num > 0) {
|
||||
@@ -256,13 +331,33 @@ class PasswordResetManager {
|
||||
return str_pad($result, 6, 'A', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask an email address for display in the UI.
|
||||
*
|
||||
* Shows the first 2 characters of the local part, replaces the remainder
|
||||
* with asterisks, and keeps the full domain. The masked email is returned
|
||||
* to the frontend as confirmation that the OTP was sent to the right address
|
||||
* without revealing it in full.
|
||||
*
|
||||
* Example: "john.doe@example.com" → "jo******@example.com"
|
||||
*
|
||||
* @param string $email The full email address to mask.
|
||||
* @return string Masked email address.
|
||||
*/
|
||||
private function maskEmail(string $email): string {
|
||||
$at = strpos($email, '@');
|
||||
$at = strpos($email, '@');
|
||||
return substr($email, 0, 2)
|
||||
. str_repeat('*', max(1, $at - 2))
|
||||
. substr($email, $at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove reset-related keys from the current session.
|
||||
*
|
||||
* Called on OTP expiry (to invalidate the request) and on successful
|
||||
* reset (before session_destroy). Does not destroy the full session —
|
||||
* only the 4 reset-specific keys are unset.
|
||||
*/
|
||||
private function clearSession(): void {
|
||||
unset(
|
||||
$_SESSION['reset_otp'],
|
||||
|
||||
Reference in New Issue
Block a user