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:
Thanakorn
2026-09-24 14:53:40 +07:00
parent ae98dcdcdd
commit 73c680e844
16 changed files with 538 additions and 282 deletions
+99 -134
View File
@@ -4,42 +4,31 @@
*
* Called by: login page AJAX "Resend OTP" button on the OTP input screen.
* Input: All data sourced from $_SESSION (written by login_otp.php).
* No new user input is accepted — credentials are re-read from session
* to avoid re-exposing the password in a second HTTP request.
* No new user input is accepted.
*
* 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
* reachable after login_otp.php has successfully validated credentials and
* written the login session state.
* reachable while a pending login exists: login_otp.php verified the password
* 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:
* 1. Reload username, password, and user_id from session.
* 2. Fetch the full user row (need the password hash to regenerate OTP
* and the email address to resend to).
* 3. Re-verify the stored password against the session-stored hash.
* This is a safety re-check — the session could theoretically have been
* tampered with between login_otp.php and this call.
* 4. On password mismatch → clear cookies, return "Incorrect Password".
* 5. On success:
* a. Generate a fresh 6-digit TOTP (new timestamp → new OTP).
* b. Generate a new 6-letter reference number.
* 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!" }.
* 1. Require a fresh pending login; otherwise HTTP 401 (code "login_restart").
* 2. Throttle: per client IP, per user, and at most LOGIN_OTP_MAX_RESENDS
* resends per pending login (HTTP 429).
* 3. Fetch the user row (password hash for the OTP, email to send to).
* 4. Generate a fresh 6-digit TOTP (new timestamp → new OTP) and a random
* 6-letter reference number.
* 5. Send the OTP email with the same SMTP choice as login_otp.php: the
* default company's SMTP when configured, otherwise the system $SMTP.
* 6. Write the new OTP state (the wrong-attempt counter restarts).
* 7. Return { success: 1, message: "Login Complete!" }.
*
* Session keys read:
* login_data['username'], login_data['password'], login_user_id
* login_user_id, password_verified_at, otp_resends
*
* Session keys overwritten:
* login_data, otp, otpTime, reference, user_email, login_user_id
* (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" }
* otp, otpTime, reference, user_email, otp_attempts, otp_resends
*/
require_once '../../../session.php';
@@ -47,120 +36,96 @@ require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
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 ───────────────────────────────────
// These were stored by login_otp.php so the user doesn't have to retype them.
$data["username"] = $_SESSION["login_data"]['username'];
$data["password"] = $_SESSION["login_data"]['password'];
$user_id = (int)$_SESSION["login_user_id"];
// ── Step 1: Require a pending login ───────────────────────────────────────────
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 ─────────────────────────────────────────────────
// ── 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->execute([":user_id" => $user_id]);
$temp = $sth->fetch(PDO::FETCH_ASSOC);
$user_email = $temp["email"];
// ── Step 3–4: Re-verify password ─────────────────────────────────────────────
// 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.
// A new $otpTime is captured so the OTP window resets from this moment.
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
global $otpTime;
$otpTime = time(); // new timestamp — extends the 5-minute validity window
$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 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";
if (true) {
$mailer = new mailer(["pdo1" => $pdo1]);
$mailer->send_email([
"company_id" => 0,
"smtp" => $SMTP,
"subject" => "One Time Password (OTP) For reference number " . $reference_number,
"message" => implode("\n", [
"Dear WMS user,",
"",
"You requested a One-Time Password (OTP) to log in to WMS.",
"",
"Please use the OTP below to complete your request:",
"• OTP code: " . $otp,
"• Reference number: " . $reference_number,
"",
"Please note:",
"• This code will expire in 3 minutes. Please complete your action promptly.",
"• Do not share this code with anyone to keep your account secure.",
"• If you did not request this code, please ignore this email.",
]),
"channel_name" => "WMS LOGIN OTP ",
"to" => $user_email,
"key" => $pinkey,
]);
}
// ── Step 5d: Reset session with new OTP state ─────────────────────────────
// Full session is cleared before repopulating to avoid stale state
// from the previous OTP attempt leaking into this one.
if (!$temp) {
$_SESSION = [];
$_SESSION["login_data"] = $data;
$_SESSION["otp"] = $otp;
$_SESSION["otpTime"] = $otpTime; // new timestamp — login_confirm.php uses this
$_SESSION["reference"] = $reference_number;
$_SESSION["user_email"] = $user_email;
$_SESSION["login_user_id"] = $user_id;
// ── Step 6: Respond ───────────────────────────────────────────────────────
$answer["success"] = 1;
$answer["message"] = "Login Complete!";
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));
login_fail(401, 'Your sign-in has expired. Please enter your username and password again.', ['code' => 'login_restart']);
}
$user_email = $temp["email"];
// ── Step 4: Generate fresh 6-digit TOTP + random reference ────────────────────
// 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.
$otpTime = time();
$otp = login_generate_otp($temp["password"], $otpTime);
$reference_number = login_random_reference();
// ── Step 5: Send OTP email ────────────────────────────────────────────────────
// Company SMTP of the user's default company when configured, otherwise the
// system-level $SMTP from config.php.
$smtp_config = $SMTP;
$default_company = (int)($temp["default_company"] ?? 0);
if ($default_company > 0) {
$sth = $pdo1->prepare("SELECT * FROM company_smtp WHERE company_id = :cid LIMIT 1");
$sth->execute([":cid" => $default_company]);
$smtp_row = $sth->fetch(PDO::FETCH_ASSOC);
if (!empty($smtp_row)) {
$smtp_config = $smtp_row;
}
}
require "../../../assets/utils/module/mailer.php";
$mailer = new mailer(["pdo1" => $pdo1, "pdo2" => $pdo2]);
$mailer->send_email([
"company_id" => $smtp_config === $SMTP ? 0 : $default_company,
"smtp" => $smtp_config,
"subject" => "One Time Password (OTP) For reference number " . $reference_number,
"message" => implode("\n", [
"Dear WMS user,",
"",
"You requested a One-Time Password (OTP) to log in to WMS.",
"",
"Please use the OTP below to complete your request:",
"• OTP code: " . $otp,
"• Reference number: " . $reference_number,
"",
"Please note:",
"• This code will expire in 3 minutes. Please complete your action promptly.",
"• Do not share this code with anyone to keep your account secure.",
"• If you did not request this code, please ignore this email.",
]),
"channel_name" => "WMS LOGIN OTP ",
"to" => $user_email,
"key" => $pinkey,
]);
// ── Step 6: Write the new OTP state ───────────────────────────────────────────
// The pending-login keys (login_data, login_user_id, password_verified_at) stay
// as they are; only the OTP state is replaced.
$_SESSION["otp"] = $otp;
$_SESSION["otpTime"] = $otpTime; // new timestamp — login_confirm.php uses this
$_SESSION["reference"] = $reference_number;
$_SESSION["user_email"] = $user_email;
$_SESSION["otp_attempts"] = 0;
$_SESSION["otp_resends"] = (int)($_SESSION["otp_resends"] ?? 0) + 1;
// ── Step 7: Respond ───────────────────────────────────────────────────────────
$answer["success"] = 1;
exit(json_encode($answer));
$answer["message"] = "Login Complete!";
exit(json_encode($answer));