564 lines
23 KiB
PHP
564 lines
23 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Models\UserModel;
|
|
use App\Models\OrganizationModel;
|
|
use App\Models\OrganizationUserModel;
|
|
use App\Libraries\EmailLibrary;
|
|
use App\Services\RateLimitService;
|
|
|
|
class Auth extends BaseController
|
|
{
|
|
protected $emailLibrary;
|
|
protected ?RateLimitService $rateLimitService;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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));
|
|
$tokenExpiresAt = date('Y-m-d H:i:s', strtotime('+24 hours'));
|
|
|
|
// 1. Создаем пользователя с токеном верификации
|
|
$userData = [
|
|
'name' => $this->request->getPost('name'),
|
|
'email' => $this->request->getPost('email'),
|
|
'password' => $this->request->getPost('password'), // Хешируется в модели
|
|
'verification_token' => $verificationToken,
|
|
'token_expires_at' => $tokenExpiresAt,
|
|
'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 (!empty($user['token_expires_at']) && strtotime($user['token_expires_at']) < time()) {
|
|
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));
|
|
$newExpiresAt = date('Y-m-d H:i:s', strtotime('+24 hours'));
|
|
$userModel->update($user['id'], [
|
|
'verification_token' => $newToken,
|
|
'token_expires_at' => $newExpiresAt
|
|
]);
|
|
|
|
// Отправляем письмо повторно
|
|
$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 не подтверждён. Проверьте почту или <a href="/auth/resend-verification">запросите письмо повторно</a>.');
|
|
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
|
|
];
|
|
|
|
// === ЗАПОМНИТЬ МЕНЯ ===
|
|
$remember = $this->request->getPost('remember');
|
|
$redirectUrl = count($userOrgs) === 1 ? '/' : '/organizations';
|
|
|
|
if ($remember) {
|
|
$redirectUrl = $this->createRememberTokenAndRedirect($user['id'], $redirectUrl);
|
|
}
|
|
|
|
// АВТОМАТИЧЕСКИЙ ВЫБОР ОРГАНИЗАЦИИ
|
|
if (count($userOrgs) === 1) {
|
|
// Если одна организация — заходим автоматически для удобства
|
|
$sessionData['active_org_id'] = $userOrgs[0]['organization_id'];
|
|
session()->set($sessionData);
|
|
|
|
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА ===
|
|
$this->resetRateLimit('login');
|
|
|
|
return $redirectUrl !== '/'
|
|
? redirect()->to($redirectUrl)
|
|
: 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 $redirectUrl !== '/' && $redirectUrl !== '/organizations'
|
|
? redirect()->to($redirectUrl)
|
|
: 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()
|
|
{
|
|
$userId = session()->get('user_id');
|
|
|
|
// Удаляем все remember-токены пользователя
|
|
if ($userId) {
|
|
$db = \Config\Database::connect();
|
|
$db->table('remember_tokens')->where('user_id', $userId)->delete();
|
|
}
|
|
|
|
session()->destroy();
|
|
session()->remove('active_org_id');
|
|
return redirect()->to('/');
|
|
}
|
|
|
|
/**
|
|
* Создание remember-токена для автологина
|
|
* Возвращает массив с selector и validator
|
|
*/
|
|
protected function createRememberTokenData(int $userId): array
|
|
{
|
|
$selector = bin2hex(random_bytes(16));
|
|
$validator = bin2hex(random_bytes(32));
|
|
$tokenHash = hash('sha256', $validator);
|
|
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
|
|
|
|
$db = \Config\Database::connect();
|
|
$db->table('remember_tokens')->insert([
|
|
'user_id' => $userId,
|
|
'selector' => $selector,
|
|
'token_hash' => $tokenHash,
|
|
'expires_at' => $expiresAt,
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
'user_agent' => $this->request->getUserAgent()->getAgentString(),
|
|
'ip_address' => $this->request->getIPAddress(),
|
|
]);
|
|
|
|
return [
|
|
'selector' => $selector,
|
|
'validator' => $validator,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Создание токена и возврат редиректа с установленными куками
|
|
*/
|
|
protected function createRememberTokenAndRedirect(int $userId, string $redirectUrl)
|
|
{
|
|
$tokenData = $this->createRememberTokenData($userId);
|
|
|
|
$redirect = redirect()->to($redirectUrl);
|
|
$redirect->setCookie('remember_selector', $tokenData['selector'], 30 * 24 * 60 * 60);
|
|
$redirect->setCookie('remember_token', $tokenData['validator'], 30 * 24 * 60 * 60);
|
|
|
|
return $redirectUrl;
|
|
}
|
|
|
|
/**
|
|
* Проверка remember-токена (вызывается перед фильтрами авторизации)
|
|
* Возвращает user_id если токен валиден, иначе null
|
|
*/
|
|
public static function checkRememberToken(): ?int
|
|
{
|
|
$request = \Config\Services::request();
|
|
$selector = $request->getCookie('remember_selector');
|
|
$validator = $request->getCookie('remember_token');
|
|
|
|
if (!$selector || !$validator) {
|
|
return null;
|
|
}
|
|
|
|
$db = \Config\Database::connect();
|
|
$token = $db->table('remember_tokens')
|
|
->where('selector', $selector)
|
|
->where('expires_at >', date('Y-m-d H:i:s'))
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if (!$token) {
|
|
return null;
|
|
}
|
|
|
|
$tokenHash = hash('sha256', $validator);
|
|
if (!hash_equals($token['token_hash'], $tokenHash)) {
|
|
return null;
|
|
}
|
|
|
|
return (int) $token['user_id'];
|
|
}
|
|
|
|
/**
|
|
* 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'],
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
}
|