emailLibrary = new EmailLibrary(); // Инициализируем rate limiting сервис (может быть null если Redis недоступен) try { $this->rateLimitService = RateLimitService::getInstance(); } catch (\Exception $e) { // Если Redis недоступен - логируем и продолжаем без rate limiting log_message('warning', 'RateLimitService недоступен: ' . $e->getMessage()); $this->rateLimitService = null; } } /** * Проверка rate limiting перед действием * * @param string $action Тип действия (login, register, reset) * @return array|null Возвращает данные об ошибке если заблокирован, иначе null */ protected function checkRateLimit(string $action): ?array { // Если сервис недоступен - пропускаем проверку if ($this->rateLimitService === null) { return null; } // Проверяем блокировку if ($this->rateLimitService->isBlocked($action)) { $ttl = $this->rateLimitService->getBlockTimeLeft($action); return [ 'blocked' => true, 'message' => "Слишком много попыток. Повторите через {$ttl} секунд.", 'ttl' => $ttl, ]; } return null; } /** * Запись неудачной попытки * * @param string $action Тип действия * @return array|null Данные о превышении лимита или null */ protected function recordFailedAttempt(string $action): ?array { // Если сервис недоступен - пропускаем if ($this->rateLimitService === null) { return null; } $result = $this->rateLimitService->recordFailedAttempt($action); // Если после записи попыток превышен лимит - возвращаем данные для отображения if ($result['blocked']) { return [ 'blocked' => true, 'message' => "Превышено максимальное количество попыток. Доступ заблокирован на {$result['block_ttl']} секунд.", 'ttl' => $result['block_ttl'], ]; } return null; } /** * Сброс счётчика после успешного действия * * @param string $action Тип действия * @return void */ protected function resetRateLimit(string $action): void { if ($this->rateLimitService !== null) { $this->rateLimitService->resetAttempts($action); } } public function register() { if ($this->request->getMethod() === 'POST') { // === ПРОВЕРКА RATE LIMITING === $rateLimitError = $this->checkRateLimit('register'); if ($rateLimitError !== null) { return redirect()->back() ->with('error', $rateLimitError['message']) ->withInput(); } log_message('debug', 'POST запрос получен: ' . print_r($this->request->getPost(), true)); // Валидация $rules = [ 'name' => 'required|min_length[3]', 'email' => 'required|valid_email|is_unique[users.email]', 'password' => 'required|min_length[6]', ]; if (!$this->validate($rules)) { return redirect()->back()->with('error', 'Ошибка регистрации'); } $userModel = new UserModel(); $orgModel = new OrganizationModel(); $orgUserModel = new OrganizationUserModel(); // Генерируем токен для подтверждения email $verificationToken = bin2hex(random_bytes(32)); // 1. Создаем пользователя с токеном верификации $userData = [ 'name' => $this->request->getPost('name'), 'email' => $this->request->getPost('email'), 'password' => $this->request->getPost('password'), // Хешируется в модели 'verification_token' => $verificationToken, 'email_verified' => 0, ]; log_message('debug', 'Registration userData: ' . print_r($userData, true)); $userId = $userModel->insert($userData); log_message('debug', 'Insert result, userId: ' . $userId); // 2. Создаем "Личную организацию" (п. 5.2.1 ТЗ) $orgData = [ 'owner_id' => $userId, 'name' => 'Личное пространство', 'type' => 'personal', ]; $orgId = $orgModel->insert($orgData); // 3. Привязываем пользователя к этой организации (роль owner) $orgUserModel->insert([ 'organization_id' => $orgId, 'user_id' => $userId, 'role' => 'owner', 'status' => 'active', 'joined_at' => date('Y-m-d H:i:s'), ]); // 4. Отправляем письмо для подтверждения email $this->emailLibrary->sendVerificationEmail( $userData['email'], $userData['name'], $verificationToken ); // === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОЙ РЕГИСТРАЦИИ === $this->resetRateLimit('register'); // 5. Показываем сообщение о необходимости подтверждения session()->setFlashdata('success', 'Регистрация успешна! Пожалуйста, проверьте вашу почту и подтвердите email.'); return redirect()->to('/register/success'); } return $this->renderTwig('auth/register'); } /** * Страница после успешной регистрации */ public function registerSuccess() { return $this->renderTwig('auth/register_success'); } /** * Подтверждение email по токену */ public function verify($token) { log_message('debug', 'Verify called with token: ' . $token); if (empty($token)) { return $this->renderTwig('auth/verify_error', [ 'message' => 'Отсутствует токен подтверждения.' ]); } $userModel = new UserModel(); // Ищем пользователя по токену $user = $userModel->where('verification_token', $token)->first(); log_message('debug', 'User found: ' . ($user ? 'yes' : 'no')); if ($user) { log_message('debug', 'User email_verified: ' . $user['email_verified']); } if (!$user) { return $this->renderTwig('auth/verify_error', [ 'message' => 'Недействительная ссылка для подтверждения. Возможно, ссылка уже была использована или истек срок её действия.' ]); } if ($user['email_verified']) { return $this->renderTwig('auth/verify_error', [ 'message' => 'Email уже подтверждён. Вы можете войти в систему.' ]); } // Подтверждаем email $updateData = [ 'email_verified' => 1, 'verified_at' => date('Y-m-d H:i:s'), 'verification_token' => null, // Удаляем токен после использования ]; $result = $userModel->update($user['id'], $updateData); log_message('debug', 'Update result: ' . ($result ? 'success' : 'failed')); log_message('debug', 'Update data: ' . print_r($updateData, true)); if (!$result) { log_message('error', 'Update errors: ' . print_r($userModel->errors(), true)); } // Отправляем приветственное письмо $this->emailLibrary->sendWelcomeEmail($user['email'], $user['name']); return $this->renderTwig('auth/verify_success', [ 'name' => $user['name'] ]); } /** * Повторная отправка письма для подтверждения */ public function resendVerification() { if ($this->request->getMethod() === 'POST') { // === ПРОВЕРКА RATE LIMITING === $rateLimitError = $this->checkRateLimit('reset'); if ($rateLimitError !== null) { return redirect()->back() ->with('error', $rateLimitError['message']) ->withInput(); } $email = $this->request->getPost('email'); if (empty($email)) { return redirect()->back()->with('error', 'Введите email'); } $userModel = new UserModel(); $user = $userModel->where('email', $email)->first(); if (!$user) { // Неудачная попытка - засчитываем для защиты от перебора $this->recordFailedAttempt('reset'); return redirect()->back()->with('error', 'Пользователь с таким email не найден'); } if ($user['email_verified']) { return redirect()->to('/login')->with('info', 'Email уже подтверждён. Вы можете войти.'); } // Генерируем новый токен $newToken = bin2hex(random_bytes(32)); $userModel->update($user['id'], [ 'verification_token' => $newToken ]); // Отправляем письмо повторно $this->emailLibrary->sendVerificationEmail( $user['email'], $user['name'], $newToken ); // === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОЙ ОТПРАВКИ === $this->resetRateLimit('reset'); return redirect()->back()->with('success', 'Письмо для подтверждения отправлено повторно. Проверьте почту.'); } return $this->renderTwig('auth/resend_verification'); } public function login() { if ($this->request->getMethod() === 'POST') { // === ПРОВЕРКА RATE LIMITING === $rateLimitError = $this->checkRateLimit('login'); if ($rateLimitError !== null) { return redirect()->back() ->with('error', $rateLimitError['message']) ->withInput(); } $userModel = new \App\Models\UserModel(); $orgUserModel = new \App\Models\OrganizationUserModel(); $email = $this->request->getPost('email'); $password = $this->request->getPost('password'); $user = $userModel->where('email', $email)->first(); if ($user && password_verify($password, $user['password'])) { // Проверяем, подтвержден ли email if (!$user['email_verified']) { session()->setFlashdata('warning', 'Email не подтверждён. Проверьте почту или запросите письмо повторно.'); return redirect()->to('/login'); } // Получаем организации пользователя $userOrgs = $orgUserModel->where('user_id', $user['id'])->findAll(); if (empty($userOrgs)) { // Экстремальный случай: если по какой-то причине у пользователя нет организаций session()->setFlashdata('error', 'Ваш аккаунт не привязан ни к одной организации. Обратитесь к поддержке.'); return redirect()->to('/login'); } // Базовые данные сессии (пользователь авторизован) $sessionData = [ 'user_id' => $user['id'], 'email' => $user['email'], 'name' => $user['name'], 'isLoggedIn' => true ]; // АВТОМАТИЧЕСКИЙ ВЫБОР ОРГАНИЗАЦИИ if (count($userOrgs) === 1) { // Если одна организация — заходим автоматически для удобства $sessionData['active_org_id'] = $userOrgs[0]['organization_id']; session()->set($sessionData); // === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА === $this->resetRateLimit('login'); return redirect()->to('/'); } // ОЧИЩАЕМ active_org_id если несколько организаций session()->remove('active_org_id'); // Если несколько организаций — отправляем на страницу выбора session()->set($sessionData); // (Опционально) Записываем информацию, что пользователь залогинен, но орга не выбрана, // чтобы страница /organizations не редиректнула его обратно (см. Organizations::index) session()->setFlashdata('info', 'Выберите пространство для работы'); // === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА === $this->resetRateLimit('login'); return redirect()->to('/organizations'); } else { // === ЗАСЧИТЫВАЕМ НЕУДАЧНУЮ ПОПЫТКУ === $limitExceeded = $this->recordFailedAttempt('login'); // Если лимит превышен - показываем сообщение if ($limitExceeded !== null && $limitExceeded['blocked']) { $message = "Слишком много неудачных попыток входа. "; $message .= "Доступ заблокирован на " . $this->formatBlockTime($limitExceeded['ttl']) . "."; return redirect()->back()->with('error', $message)->withInput(); } // Иначе показываем стандартное сообщение об ошибке $remaining = $this->rateLimitService ? $this->rateLimitService->checkAttempt('login')['remaining'] : 0; $message = 'Неверный логин или пароль'; if ($remaining > 0 && $remaining <= 2) { $message .= " Осталось попыток: {$remaining}"; } return redirect()->back()->with('error', $message)->withInput(); } } return $this->renderTwig('auth/login'); } public function logout() { session()->destroy(); session()->remove('active_org_id'); return redirect()->to('/'); } /** * DEBUG: Просмотр состояния rate limiting (только для разработки) * DELETE: Убрать перед релизом! * * GET /auth/rate-limit-status */ public function rateLimitStatus() { // В продакшене должен быть доступ только админам if (env('CI_ENVIRONMENT') === 'production') { return $this->response->setStatusCode(403)->setJSON(['error' => 'Forbidden']); } if ($this->rateLimitService === null) { return $this->response->setJSON([ 'status' => 'unavailable', 'message' => 'RateLimitService недоступен (Redis не подключен)', ]); } $loginStatus = $this->rateLimitService->getStatus('login'); $registerStatus = $this->rateLimitService->getStatus('register'); $resetStatus = $this->rateLimitService->getStatus('reset'); return $this->response->setJSON([ 'ip' => service('request')->getIPAddress(), 'redis_connected' => $this->rateLimitService->isConnected(), 'rate_limiting' => [ 'login' => [ 'attempts' => $loginStatus['attempts'], 'limit' => $loginStatus['limit'], 'window_seconds' => $loginStatus['window'], 'is_blocked' => $loginStatus['is_blocked'], 'block_ttl_seconds' => $loginStatus['block_ttl'], ], 'register' => [ 'attempts' => $registerStatus['attempts'], 'limit' => $registerStatus['limit'], 'window_seconds' => $registerStatus['window'], 'is_blocked' => $registerStatus['is_blocked'], 'block_ttl_seconds' => $registerStatus['block_ttl'], ], 'reset' => [ 'attempts' => $resetStatus['attempts'], 'limit' => $resetStatus['limit'], 'window_seconds' => $resetStatus['window'], 'is_blocked' => $resetStatus['is_blocked'], 'block_ttl_seconds' => $resetStatus['block_ttl'], ], ], ]); } }