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:
@@ -13,7 +13,8 @@
|
||||
* 1. Resolve user_id by username or email (case-insensitive).
|
||||
* 2. Fetch hashed password and full user record.
|
||||
* 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:
|
||||
* a. Email format guard (malformed email → block with message).
|
||||
* b. Unverified account (status = 'pending'):
|
||||
@@ -31,17 +32,20 @@
|
||||
* - Note: 'support' user and 'lord' licence bypass this check.
|
||||
* 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).
|
||||
* 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.
|
||||
* If no SMTP configured → skip email, set skip_otp flag in response.
|
||||
* 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!" }.
|
||||
* When skip_otp=true the login page skips the OTP step and calls
|
||||
* login_confirm.php directly.
|
||||
*
|
||||
* 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
|
||||
* otpTime — Unix timestamp the OTP was generated (used for expiry check)
|
||||
* reference — 6-letter reference code shown on the OTP screen
|
||||
@@ -51,7 +55,7 @@
|
||||
*
|
||||
* Response JSON:
|
||||
* 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
|
||||
* { "message": "block" } — device is blacklisted
|
||||
* { "expire": "expire" } — licence has expired
|
||||
@@ -63,28 +67,36 @@ require_once '../../../preset.php';
|
||||
define('UNAUTHENTICATED_ROUTE', true);
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/otp_policy.php';
|
||||
require_once '../../../assets/utils/rate_limit.php';
|
||||
require_once '../login_helpers.php';
|
||||
|
||||
$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) ────────
|
||||
$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();
|
||||
|
||||
$username = strtolower($data["username"]);
|
||||
|
||||
// ── 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->execute(array($username, $username));
|
||||
$temp = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// ── 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
|
||||
// whether an account exists via a different error message.
|
||||
// A locked account gets the same generic answer as a wrong password, so the
|
||||
// lockout cannot be used to confirm that an account exists.
|
||||
if ($user_id && !empty($temp['locked_until'])) {
|
||||
if (strtotime($temp['locked_until']) > time()) {
|
||||
// Still within the lockout window — reject
|
||||
$retry_at = date('H:i', strtotime($temp['locked_until']));
|
||||
$answer['message'] = "Too many failed attempts. Please try again after {$retry_at}.";
|
||||
exit(json_encode($answer));
|
||||
login_fail(401, LOGIN_GENERIC_FAILURE);
|
||||
} else {
|
||||
// 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")
|
||||
@@ -94,7 +106,7 @@ if ($user_id && !empty($temp['locked_until'])) {
|
||||
}
|
||||
|
||||
// ── 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 ─────────────────────
|
||||
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
|
||||
// the OTP email delivery step further down doesn't silently fail.
|
||||
if (strpos($user_email, "@") === false) {
|
||||
$answer["message"] = "<b>" . $user_email . "</b> is not eligible email, please contact your administrator to change your email.";
|
||||
exit(json_encode($answer));
|
||||
// The message is rendered as HTML by bootbox — escape the stored value.
|
||||
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') ─────────────────────
|
||||
@@ -166,6 +178,7 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
||||
'key' => $pinkey,
|
||||
]);
|
||||
|
||||
http_response_code(403);
|
||||
if ($mail_sent) {
|
||||
$answer["message"] = "Your email is not verified. We've sent a new verification link to your inbox — please check your email.";
|
||||
} else {
|
||||
@@ -177,8 +190,7 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
||||
|
||||
// ── Step 5d: Deactivated account ─────────────────────────────────────────
|
||||
if ($r["status"] === "not activated") {
|
||||
$answer["message"] = "Your account has been deactivated. Please contact your administrator.";
|
||||
exit(json_encode($answer));
|
||||
login_fail(403, "Your account has been deactivated. Please contact your administrator.");
|
||||
}
|
||||
|
||||
// ── 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"]));
|
||||
|
||||
session_destroy();
|
||||
http_response_code(403);
|
||||
$answer["message"] = "wait";
|
||||
setcookie("u", "", time() - 1, "/");
|
||||
setcookie("h1", "", 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 {
|
||||
|
||||
@@ -216,6 +230,7 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
||||
|
||||
// Device explicitly blocked by admin
|
||||
session_destroy();
|
||||
http_response_code(403);
|
||||
$answer["message"] = "block";
|
||||
setcookie("u", "", time() - 1, "/");
|
||||
setcookie("h1", "", time() - 1, "/");
|
||||
@@ -223,11 +238,13 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
||||
echo json_encode($answer);
|
||||
|
||||
$deviceDecision = ['type' => 'BLOCKED', 'status' => 0];
|
||||
exit;
|
||||
|
||||
} else if ($coo["status"] == "1") {
|
||||
|
||||
// Device registered but not yet approved — notify admin
|
||||
session_destroy();
|
||||
http_response_code(403);
|
||||
$answer["message"] = "wait";
|
||||
setcookie("u", "", 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 (strtotime("now") > strtotime($expire . " + 1 day")) {
|
||||
session_destroy();
|
||||
$answer["expire"] = "expire";
|
||||
http_response_code(403);
|
||||
$answer["expire"] = "expire";
|
||||
$answer["message"] = "Your licence has expired. Please contact your administrator.";
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
@@ -273,10 +292,12 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
||||
|
||||
if (!$requires_otp) {
|
||||
$_SESSION = [];
|
||||
$_SESSION['login_data'] = $data;
|
||||
$_SESSION['login_user_id'] = $user_id;
|
||||
$_SESSION['otpTime'] = time();
|
||||
$_SESSION['skip_otp'] = true;
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['login_data'] = ['username' => $username];
|
||||
$_SESSION['password_verified_at'] = time();
|
||||
$_SESSION['login_user_id'] = $user_id;
|
||||
$_SESSION['otpTime'] = time();
|
||||
$_SESSION['skip_otp'] = true;
|
||||
$answer['success'] = 1;
|
||||
$answer['skip_otp'] = true;
|
||||
$answer['message'] = 'Login Complete!';
|
||||
@@ -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
|
||||
// per user and automatically invalidated if the password changes.
|
||||
// time_step=180 means the OTP window is 3 minutes (same counter for 3 min).
|
||||
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
|
||||
|
||||
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);
|
||||
}
|
||||
$otpTime = time();
|
||||
$otp = login_generate_otp($temp["password"], $otpTime);
|
||||
|
||||
// ── Step 7: Generate 6-letter reference number ───────────────────────────
|
||||
// Converts a second TOTP (derived from the first OTP as key) to a base-26
|
||||
// uppercase letter string. Shown on the OTP screen so the user can confirm
|
||||
// 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));
|
||||
// Random, shown on the OTP screen so the user can match it to the email.
|
||||
$reference_number = login_random_reference();
|
||||
|
||||
// ── Step 8: Look up company SMTP and send OTP email ──────────────────────
|
||||
// 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
|
||||
// from a previous partial login attempt is discarded before writing new state.
|
||||
$_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["otpTime"] = $otpTime; // timestamp for the 5-minute expiry window
|
||||
$_SESSION["reference"] = $reference_number; // shown on OTP input screen
|
||||
@@ -394,27 +393,23 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
||||
// ── Password mismatch ─────────────────────────────────────────────────────
|
||||
// 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.
|
||||
// Every failure gets the same generic message (no username enumeration).
|
||||
if ($user_id) {
|
||||
$attempts = (int)($temp['login_attempts'] ?? 0) + 1;
|
||||
if ($attempts >= 5) {
|
||||
$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")
|
||||
->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 {
|
||||
$pdo1->prepare("UPDATE user SET login_attempts = :a WHERE user_id = :id")
|
||||
->execute([':a' => $attempts, ':id' => $user_id]);
|
||||
$answer['message'] = "Incorrect Password";
|
||||
}
|
||||
} else {
|
||||
$answer['message'] = "Incorrect Username";
|
||||
}
|
||||
|
||||
setcookie("u", "", time() - 1, "/");
|
||||
setcookie("h1", "", time() - 1, "/");
|
||||
setcookie("h2", "", time() - 1, "/");
|
||||
exit(json_encode($answer));
|
||||
login_fail(401, LOGIN_GENERIC_FAILURE);
|
||||
}
|
||||
|
||||
$answer["success"] = 1;
|
||||
|
||||
Reference in New Issue
Block a user