connect()->prepare('SELECT * FROM view_users WHERE user_email = :email OR user_name = :name LIMIT 1'); $stmt->execute([':email' => trim($identifier), ':name' => trim($identifier)]); return $stmt->fetch() ?: null; } public static function passwordError(string $password, string $confirmation): string { if ($password !== $confirmation) return 'Passwords must match.'; // PASSWORD_DEFAULT is bcrypt on supported PHP versions; avoid silent truncation. if (strlen($password) < 12 || strlen($password) > 72 || str_contains($password, "\0")) { return 'Use a password between 12 and 72 bytes long.'; } return ''; } /** Shared database counters survive session/cookie changes. Requires the auth migration. */ public function allowAttempt(string $scope, string $identifier, int $limit, int $seconds): bool { $db = $this->connect(); $key = hash('sha256', $scope . ':' . strtolower(trim($identifier))); $stmt = $db->prepare('INSERT INTO auth_attempts (attempt_key, attempts, window_started) VALUES (:key, 1, UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE attempts = IF(window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :seconds SECOND), 1, attempts + 1), window_started = IF(window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :window SECOND), UTC_TIMESTAMP(), window_started)'); $stmt->execute([':key' => $key, ':seconds' => $seconds, ':window' => $seconds]); $stmt = $db->prepare('SELECT attempts FROM auth_attempts WHERE attempt_key = :key'); $stmt->execute([':key' => $key]); return (int) $stmt->fetchColumn() <= $limit; } public function login(string $identifier, string $password): bool { $ipAllowed = $this->allowAttempt('login-ip', $_SERVER['REMOTE_ADDR'] ?? '', 50, 900); $accountAllowed = $this->allowAttempt('login-account', $identifier, 10, 900); if (!$ipAllowed || !$accountAllowed) return false; $user = $this->findUser($identifier); // A real hash avoids an immediate return for unknown accounts. $hash = $user['user_password_hash'] ?? '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.'; $verified = password_verify($password, $hash); if (!$user || !$verified || $user['user_status'] !== 'active') return false; if (password_needs_rehash($hash, PASSWORD_DEFAULT)) { $stmt = $this->connect()->prepare('UPDATE users SET user_password_hash = :hash WHERE user_serial = :id AND user_password_hash = :old_hash'); $stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['user_serial'], ':old_hash' => $hash]); } session_regenerate_id(true); unset($_SESSION['mfa_user'],$_SESSION['recent_auth'],$_SESSION['mfa_pending'],$_SESSION['mfa_replacement']); $_SESSION['authenticated_at']=time(); $_SESSION[self::USER_AUTHENTICATED] = true; $_SESSION[self::USER_SERIAL] = (int) $user['user_serial']; $_SESSION[self::USER_NAME] = $user['user_name']; $_SESSION[self::USER_FULL_NAME] = $user['user_full_name']; $_SESSION[self::USER_EMAIL] = $user['user_email']; $_SESSION[self::USER_ROLE] = $user['user_role']; $_SESSION['auth_version'] = (int) $user['user_auth_version']; $_SESSION['last_activity'] = time(); $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $stmt = $this->connect()->prepare('UPDATE users SET user_last_login = :now WHERE user_serial = :id'); $stmt->execute([':now' => $this->getTimestamp(), ':id' => $user['user_serial']]); Audit::record($this->connect(),(int)$user['user_serial'],'logged in ('.$user['user_role'].')'); return true; } public function currentUser(): ?array { if (empty($_SESSION[self::USER_AUTHENTICATED])) return null; if (time() - ($_SESSION['last_activity'] ?? 0) > self::IDLE_TIMEOUT || time() - ($_SESSION['authenticated_at'] ?? 0) >= self::ABSOLUTE_TIMEOUT) return null; $stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_serial = :id'); $stmt->execute([':id' => $_SESSION[self::USER_SERIAL] ?? 0]); $user = $stmt->fetch(); if (!$user || $user['user_status'] !== 'active' || (int) $user['user_auth_version'] !== ($_SESSION['auth_version'] ?? -1)) return null; $_SESSION['last_activity'] = time(); $_SESSION[self::USER_ROLE] = $user['user_role']; $_SESSION[self::USER_NAME] = $user['user_name']; $_SESSION[self::USER_FULL_NAME] = $user['user_full_name']; $_SESSION[self::USER_EMAIL] = $user['user_email']; return $user; } public function register(array $input): void { $first = trim($input['user_first_name'] ?? ''); $last = trim($input['user_last_name'] ?? ''); $email = trim($input['user_email'] ?? ''); $name = trim($input['user_name'] ?? ''); if (!$first || !$last || strlen($first) > 128 || strlen($last) > 128) throw new InvalidArgumentException('Enter your first and last name (up to 128 bytes each).'); if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100) throw new InvalidArgumentException('Enter a valid email address (up to 100 bytes).'); if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name)) throw new InvalidArgumentException('Use 3 to 64 letters, numbers, dots, underscores or hyphens for your username.'); $error = self::passwordError($input['user_password'] ?? '', $input['user_password_confirm'] ?? ''); if ($error) throw new InvalidArgumentException($error); $db = $this->connect(); // Cross-field uniqueness keeps email-or-username login unambiguous. $stmt = $db->prepare('SELECT user_serial FROM users WHERE user_name IN (:name, :email) OR user_email IN (:email2, :name2) LIMIT 1'); $stmt->execute([':name' => $name, ':email' => $email, ':email2' => $email, ':name2' => $name]); if ($stmt->fetch()) throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.'); $stmt = $db->prepare("INSERT INTO users (user_name, user_first_name, user_last_name, user_email, user_password_hash, user_role, user_status, user_creator) VALUES (:name, :first, :last, :email, :hash, 'User', 'active', 'Self registration')"); $kind=$input['registration_kind']??'account'; if(!in_array($kind,['account','new_club','invited'],true))throw new InvalidArgumentException('Choose a valid signup option.'); $clubName=trim($input['club_name']??''); if($kind==='new_club' && ($clubName===''||strlen($clubName)>128))throw new InvalidArgumentException('Enter your new club name (up to 128 bytes).'); try { DatabaseTransaction::run($db,function(PDO $db)use($stmt,$name,$first,$last,$email,$input,$kind,$clubName){ if($kind==='invited'){ $invites=$db->prepare("SELECT i.club_serial,i.invited_by,i.membership_role FROM club_invitations i JOIN clubs c ON c.club_serial=i.club_serial WHERE i.invite_email=? AND i.invitation_status='pending' AND i.expires_at>UTC_TIMESTAMP() AND c.club_status='active'");$invites->execute([$email]);$valid=false;$access=new TenantAccess(); foreach($invites->fetchAll() as $invite)if($access->canManage((int)$invite['club_serial'],$db,(int)$invite['invited_by'])&&($invite['membership_role']==='member'||$access->isSuper($db,(int)$invite['invited_by']))){$valid=true;break;} if(!$valid)throw new InvalidArgumentException('Ask your club administrator for a current invitation to this email address.'); } $stmt->execute([':name'=>$name,':first'=>$first,':last'=>$last,':email'=>$email,':hash'=>password_hash($input['user_password'],PASSWORD_DEFAULT)]); $user=(int)$db->lastInsertId(); if($kind==='new_club'){ $club=$db->prepare("INSERT INTO clubs(club_name,club_status) VALUES(?,'active')");$club->execute([$clubName]);$clubId=(int)$db->lastInsertId(); $membership=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,membership_role,membership_status,joined_on) VALUES(?,?,'administrator','active',?)");$membership->execute([$clubId,$user,date('Y-m-d')]); Audit::required($db,$user,'registered new club '.json_encode(['club'=>$clubId,'name'=>$clubName])); } }); } catch (PDOException $e) { if ($e->getCode() === '23000') throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.'); throw $e; } } /** Never modify the password when somebody merely requests an email. */ public function requestReset(string $identifier): bool { $ipAllowed = $this->allowAttempt('reset-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600); $accountAllowed = $this->allowAttempt('reset-account', $identifier, 3, 3600); if (!$ipAllowed || !$accountAllowed) return false; $user = $this->findUser($identifier); if (!$user || $user['user_status'] !== 'active') return false; $baseUrl = rtrim((string) $this->getSettings()->ApplicationUrl, '/'); if (!filter_var($baseUrl, FILTER_VALIDATE_URL) || parse_url($baseUrl, PHP_URL_SCHEME) !== 'https' || parse_url($baseUrl, PHP_URL_QUERY) !== null || parse_url($baseUrl, PHP_URL_FRAGMENT) !== null || parse_url($baseUrl, PHP_URL_USER) !== null || parse_url($baseUrl, PHP_URL_PASS) !== null) { throw new RuntimeException('Configure an HTTPS ApplicationUrl before sending reset emails.'); } $token = bin2hex(random_bytes(32)); $db = $this->connect(); $hash = hash('sha256', $token); $db->beginTransaction(); try { // Share the user-row lock with account edits and password changes. A // request resolved before such a change must not mint a token afterward. $stmt=$db->prepare('SELECT user_email,user_status,user_auth_version FROM users WHERE user_serial=? FOR UPDATE'); $stmt->execute([$user['user_serial']]);$current=$stmt->fetch(); if(!$current || $current['user_status']!=='active' || $current['user_email']!==$user['user_email'] || (int)$current['user_auth_version']!==(int)$user['user_auth_version']){ $db->rollBack();return false; } $stmt=$db->prepare('INSERT INTO password_resets (token_hash,user_serial,expires_at) VALUES (?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 30 MINUTE))'); $stmt->execute([$hash,$user['user_serial']]);$db->commit(); }catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;} $url = $baseUrl . '/main.php?Xaction=resetpassword&token=' . rawurlencode($token); if (!$this->deliverResetEmail($user, $url)) { $stmt = $db->prepare('DELETE FROM password_resets WHERE token_hash = :hash'); $stmt->execute([':hash' => $hash]); throw new RuntimeException('Password reset email delivery failed.'); } Audit::record($db,(int)$user['user_serial'],'requested password reset'); return true; } protected function deliverResetEmail(array $user, string $url): bool { return (new Email())->sendPasswordReset($user, $url); } public function resetPassword(string $token, string $password, string $confirmation): bool { $error = self::passwordError($password, $confirmation); if ($error) throw new InvalidArgumentException($error); if (!preg_match('/\A[a-f0-9]{64}\z/', $token)) return false; $db = $this->connect(); $db->beginTransaction(); try { // Serialize resets and password changes on the user row. $stmt = $db->prepare('SELECT user_serial FROM password_resets WHERE token_hash = :hash'); $stmt->execute([':hash' => hash('sha256', $token)]); $id = $stmt->fetchColumn(); if (!$id) { $db->rollBack(); return false; } $stmt = $db->prepare("SELECT user_serial FROM users WHERE user_serial = :id AND user_status = 'active' FOR UPDATE"); $stmt->execute([':id' => $id]); if (!$stmt->fetch()) { $db->rollBack(); return false; } $stmt = $db->prepare('SELECT token_hash FROM password_resets WHERE token_hash = :hash AND expires_at > UTC_TIMESTAMP() FOR UPDATE'); $stmt->execute([':hash' => hash('sha256', $token)]); if (!$stmt->fetch()) { $db->rollBack(); return false; } $this->storePassword($db, (int) $id, $password); $stmt=$db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_serial=?');$stmt->execute([$id]); $db->commit(); Audit::record($db,(int)$id,'reset password'); return true; } catch (Throwable $e) { if ($db->inTransaction()) $db->rollBack(); throw $e; } } public function changePassword(int $id, string $current, string $password, string $confirmation): bool { $error = self::passwordError($password, $confirmation); if ($error) throw new InvalidArgumentException($error); $db = $this->connect(); $db->beginTransaction(); try { $stmt = $db->prepare("SELECT user_password_hash, user_auth_version FROM users WHERE user_serial = :id AND user_status = 'active' FOR UPDATE"); $stmt->execute([':id' => $id]); $user = $stmt->fetch(); if (!$user || (int) $user['user_auth_version'] !== ($_SESSION['auth_version'] ?? -1) || !password_verify($current, $user['user_password_hash'])) { $db->rollBack(); return false; } $this->storePassword($db, $id, $password); $db->commit(); Audit::record($db,$id,'changed password'); return true; } catch (Throwable $e) { if ($db->inTransaction()) $db->rollBack(); throw $e; } } private function storePassword(PDO $db, int $id, string $password): void { $stmt = $db->prepare('UPDATE users SET user_password_hash = :hash, user_change_password = 0, user_auth_version = user_auth_version + 1 WHERE user_serial = :id'); $stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $id]); $stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial = :id'); $stmt->execute([':id' => $id]); } }