Files
wms-app/app/assets/utils/classes/PasswordResetManager.php
Thanakorn 73c680e844 Harden sign-in and password reset
- OTP attempt limits, constant-time compare, random reference codes
- DB-backed rate limits (429) on sign-in, OTP, reset, register, onboarding
- one generic sign-in failure message; reset request no longer reveals accounts
- no password kept in the session; real status codes on failures
2026-09-24 14:53:40 +07:00

433 lines
20 KiB
PHP

<?php
/**
* PasswordResetManager
*
* 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 — user_id resolved by caller)
*
* 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().
*
* 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 random reference number (6 uppercase letters) is also generated and emailed so the
* 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:
* 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)
*
* Session keys used (prefixed with 'reset_' to avoid collision with login OTP):
* reset_otp, reset_otp_time, reset_reference, reset_user_id, reset_attempts
*
* 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.
* - 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.
* - All DB queries use PDO prepared statements with bound parameters.
* - AJAX handler methods output JSON via json_encode (XSS-safe).
*/
class PasswordResetManager {
private $pdo1;
private $pdo2;
private $include_url;
private $SMTP;
private $pinkey;
/** OTP validity window in minutes — matches the login OTP window. */
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 $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;
$this->pdo2 = $pdo2;
$this->include_url = rtrim($include_url, '/');
$this->SMTP = $SMTP;
$this->pinkey = $pinkey;
}
// ─────────────────────────────────────────────────────────────
// Core public API
// ─────────────────────────────────────────────────────────────
/**
* Generate a TOTP, send it to the user's registered email, and store it in session.
*
* 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.
*
* 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 — 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'
);
$sth->execute([':id' => $user_id]);
$user = $sth->fetch(\PDO::FETCH_ASSOC);
if (!$user || empty($user['email'])) {
throw new \RuntimeException('No email address found for this account.');
}
// Generate 6-digit TOTP and a random 6-letter reference number
$otp_time = time();
$otp = $this->generateOTP($user['password'], $otp_time);
$reference_number = $this->randomReference();
// 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]);
$mailer->send_email([
'company_id' => $company_id,
'smtp' => $this->SMTP,
'subject' => 'Password Reset OTP — Reference: ' . $reference_number,
'message' => implode("\n", [
"Your password reset OTP is: {$otp}",
"Reference number: {$reference_number}",
"",
"This OTP is valid for " . self::OTP_EXPIRY_MINUTES . " minutes.",
"If you did not request this, please ignore this email.",
]),
'channel_name' => 'WMS Security',
'to' => $user['email'],
'key' => $this->pinkey,
]);
// 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;
$_SESSION['reset_attempts'] = 0;
return [
'masked_email' => $this->maskEmail($user['email']),
'reference' => $reference_number,
];
}
/**
* 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.
*
* 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.
*
* 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 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 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 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 — at most OTP_MAX_ATTEMPTS wrong entries per issued 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.');
}
// 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 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();
}
// ─────────────────────────────────────────────────────────────
// HTTP handlers (thin AJAX endpoint wrappers)
// ─────────────────────────────────────────────────────────────
/**
* 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 {
try {
$result = $this->requestOtp($user_id, $company_id);
echo json_encode([
'success' => 1,
'masked_email' => $result['masked_email'],
'reference' => $result['reference'],
]);
} catch (\RuntimeException $e) {
error_log('[PasswordResetManager::handleRequestOtp] ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => 0, 'message' => $e->getMessage()]);
} catch (\Exception $e) {
error_log('[PasswordResetManager::handleRequestOtp] ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => 0, 'message' => 'Failed to send OTP. Please try again.']);
}
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.
*
* 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 {
try {
$this->confirmReset(
$user_id,
$data['otp'] ?? '',
$data['new_password'] ?? '',
$data['confirm_password'] ?? ''
);
echo json_encode(['success' => 1, 'message' => 'Password reset successfully.']);
} catch (\InvalidArgumentException $e) {
http_response_code(400);
echo json_encode(['success' => 0, 'message' => $e->getMessage()]);
} catch (\Exception $e) {
error_log('[PasswordResetManager::handleConfirmReset] ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => 0, 'message' => 'Failed to reset password. Please try again.']);
}
exit;
}
// ─────────────────────────────────────────────────────────────
// 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);
$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 uppercase reference code (e.g. "BCDFHJ") for the reset email
* and the confirmation screen. Carries no information about the OTP.
*
* @return string 6-character uppercase string.
*/
private function randomReference(): string {
$result = '';
for ($i = 0; $i < 6; $i++) {
$result .= chr(65 + random_int(0, 25));
}
return $result;
}
/**
* 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, '@');
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 reset-specific keys are unset.
*/
private function clearSession(): void {
unset(
$_SESSION['reset_otp'],
$_SESSION['reset_otp_time'],
$_SESSION['reset_reference'],
$_SESSION['reset_user_id'],
$_SESSION['reset_attempts']
);
}
}