dashboard for org
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\OrganizationUserModel;
|
||||
use CodeIgniter\Database\Exceptions\DataException;
|
||||
|
||||
/**
|
||||
* AccessService - Сервис проверки прав доступа (RBAC)
|
||||
*
|
||||
* Управляет правами внутренних пользователей организации.
|
||||
* Для внешних пользователей (публичные ссылки) используется ExternalAccessService.
|
||||
*/
|
||||
class AccessService
|
||||
{
|
||||
private ?array $currentMembership = null;
|
||||
private OrganizationUserModel $orgUserModel;
|
||||
|
||||
/**
|
||||
* Роли и их уровни (для быстрого сравнения)
|
||||
*/
|
||||
public const ROLE_OWNER = 'owner';
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
public const ROLE_MANAGER = 'manager';
|
||||
public const ROLE_GUEST = 'guest';
|
||||
|
||||
/**
|
||||
* Иерархия ролей (больше = выше привилегии)
|
||||
*/
|
||||
public const ROLE_HIERARCHY = [
|
||||
self::ROLE_OWNER => 100,
|
||||
self::ROLE_ADMIN => 75,
|
||||
self::ROLE_MANAGER => 50,
|
||||
self::ROLE_GUEST => 25,
|
||||
];
|
||||
|
||||
/**
|
||||
* Права на действия
|
||||
*/
|
||||
public const PERMISSION_VIEW = 'view';
|
||||
public const PERMISSION_CREATE = 'create';
|
||||
public const PERMISSION_EDIT = 'edit';
|
||||
public const PERMISSION_DELETE = 'delete';
|
||||
public const PERMISSION_DELETE_ANY = 'delete_any';
|
||||
public const PERMISSION_MANAGE_USERS = 'manage_users';
|
||||
public const PERMISSION_MANAGE_MODULES = 'manage_modules';
|
||||
public const PERMISSION_VIEW_FINANCE = 'view_finance';
|
||||
public const PERMISSION_DELETE_ORG = 'delete_org';
|
||||
public const PERMISSION_TRANSFER_OWNER = 'transfer_owner';
|
||||
|
||||
/**
|
||||
* Матрица прав по ролям
|
||||
* Формат: [роль => [ресурс => [действия]]]
|
||||
*/
|
||||
private const ROLE_PERMISSIONS = [
|
||||
self::ROLE_OWNER => [
|
||||
'*' => ['*'], // Полный доступ ко всему
|
||||
],
|
||||
self::ROLE_ADMIN => [
|
||||
'clients' => ['view', 'create', 'edit', 'delete'],
|
||||
'deals' => ['view', 'create', 'edit', 'delete'],
|
||||
'bookings' => ['view', 'create', 'edit', 'delete'],
|
||||
'projects' => ['view', 'create', 'edit', 'delete'],
|
||||
'tasks' => ['view', 'create', 'edit', 'delete'],
|
||||
'users' => [self::PERMISSION_VIEW, self::PERMISSION_CREATE, self::PERMISSION_EDIT, self::PERMISSION_DELETE],
|
||||
self::PERMISSION_MANAGE_MODULES => [self::PERMISSION_VIEW, self::PERMISSION_EDIT],
|
||||
self::PERMISSION_VIEW_FINANCE => ['*'],
|
||||
],
|
||||
self::ROLE_MANAGER => [
|
||||
'clients' => ['view', 'create', 'edit', 'delete'],
|
||||
'deals' => ['view', 'create', 'edit', 'delete'],
|
||||
'bookings' => ['view', 'create', 'edit', 'delete'],
|
||||
'projects' => ['view', 'create', 'edit', 'delete'],
|
||||
'tasks' => ['view', 'create', 'edit', 'delete'],
|
||||
'users' => [self::PERMISSION_VIEW], // Только просмотр коллег
|
||||
],
|
||||
self::ROLE_GUEST => [
|
||||
'clients' => [self::PERMISSION_VIEW],
|
||||
'deals' => [self::PERMISSION_VIEW],
|
||||
'bookings' => [self::PERMISSION_VIEW],
|
||||
'projects' => [self::PERMISSION_VIEW],
|
||||
'tasks' => [self::PERMISSION_VIEW],
|
||||
'users' => [self::PERMISSION_VIEW],
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->orgUserModel = new OrganizationUserModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение единственного экземпляра сервиса
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function getInstance(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение текущего membership пользователя
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function getCurrentMembership(): ?array
|
||||
{
|
||||
if ($this->currentMembership !== null) {
|
||||
return $this->currentMembership;
|
||||
}
|
||||
|
||||
$userId = session()->get('user_id');
|
||||
$orgId = session()->get('active_org_id');
|
||||
|
||||
if (!$userId || !$orgId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->currentMembership = $this->orgUserModel
|
||||
->where('user_id', $userId)
|
||||
->where('organization_id', $orgId)
|
||||
->first();
|
||||
|
||||
return $this->currentMembership;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение роли текущего пользователя
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getCurrentRole(): ?string
|
||||
{
|
||||
$membership = $this->getCurrentMembership();
|
||||
return $membership['role'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, авторизован ли пользователь в организации
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isAuthenticated(): bool
|
||||
{
|
||||
return $this->getCurrentMembership() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка роли пользователя
|
||||
*
|
||||
* @param string|array $roles Роль или массив ролей для проверки
|
||||
* @return bool
|
||||
*/
|
||||
public function isRole($roles): bool
|
||||
{
|
||||
$currentRole = $this->getCurrentRole();
|
||||
if ($currentRole === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = (array) $roles;
|
||||
return in_array($currentRole, $roles, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, является ли пользователь владельцем
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isOwner(): bool
|
||||
{
|
||||
return $this->getCurrentRole() === self::ROLE_OWNER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, является ли пользователем администратором
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
$role = $this->getCurrentRole();
|
||||
return $role === self::ROLE_ADMIN || $role === self::ROLE_OWNER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, является ли пользователем менеджером или выше
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isManagerOrHigher(): bool
|
||||
{
|
||||
$role = $this->getCurrentRole();
|
||||
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_MANAGER], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка права на действие
|
||||
*
|
||||
* @param string $action Действие (view, create, edit, delete, delete_any, manage_users, etc.)
|
||||
* @param string $resource Ресурс (clients, deals, bookings, projects, tasks, users)
|
||||
* @return bool
|
||||
*/
|
||||
public function can(string $action, string $resource): bool
|
||||
{
|
||||
$role = $this->getCurrentRole();
|
||||
if ($role === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$permissions = self::ROLE_PERMISSIONS[$role] ?? [];
|
||||
|
||||
// Проверка полного доступа (*)
|
||||
if (isset($permissions['*']) && in_array('*', $permissions['*'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Проверка конкретного ресурса
|
||||
if (!isset($permissions[$resource])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resourcePermissions = $permissions[$resource];
|
||||
|
||||
// Проверка полного доступа к ресурсу
|
||||
if (in_array('*', $resourcePermissions, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($action, $resourcePermissions, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка права на просмотр
|
||||
*
|
||||
* @param string $resource
|
||||
* @return bool
|
||||
*/
|
||||
public function canView(string $resource): bool
|
||||
{
|
||||
return $this->can(self::PERMISSION_VIEW, $resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка права на создание
|
||||
*
|
||||
* @param string $resource
|
||||
* @return bool
|
||||
*/
|
||||
public function canCreate(string $resource): bool
|
||||
{
|
||||
return $this->can(self::PERMISSION_CREATE, $resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка права на редактирование
|
||||
*
|
||||
* @param string $resource
|
||||
* @return bool
|
||||
*/
|
||||
public function canEdit(string $resource): bool
|
||||
{
|
||||
return $this->can(self::PERMISSION_EDIT, $resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка права на удаление
|
||||
*
|
||||
* @param string $resource
|
||||
* @param bool $any Удаление любой записи (не только своей)
|
||||
* @return bool
|
||||
*/
|
||||
public function canDelete(string $resource, bool $any = false): bool
|
||||
{
|
||||
$action = $any ? self::PERMISSION_DELETE_ANY : self::PERMISSION_DELETE;
|
||||
return $this->can($action, $resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка права на управление пользователями
|
||||
*
|
||||
* Управление пользователями недоступно для личных пространств.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function canManageUsers(): bool
|
||||
{
|
||||
// Проверяем, что это бизнес-организация (не личное пространство)
|
||||
$orgId = session()->get('active_org_id');
|
||||
if (!$orgId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$orgModel = new \App\Models\OrganizationModel();
|
||||
$organization = $orgModel->find($orgId);
|
||||
|
||||
// Личное пространство - управление пользователями недоступно
|
||||
if ($organization && $organization['type'] === 'personal') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->can(self::PERMISSION_MANAGE_USERS, 'users');
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка права на управление модулями
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function canManageModules(): bool
|
||||
{
|
||||
return $this->can(self::PERMISSION_MANAGE_MODULES, self::PERMISSION_MANAGE_MODULES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка права на просмотр финансов
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function canViewFinance(): bool
|
||||
{
|
||||
return $this->can(self::PERMISSION_VIEW_FINANCE, self::PERMISSION_VIEW_FINANCE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка права на удаление организации
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function canDeleteOrganization(): bool
|
||||
{
|
||||
return $this->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка права на передачу прав владельца
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function canTransferOwnership(): bool
|
||||
{
|
||||
return $this->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение роли для передачи прав владельца (список допустимых ролей)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getRolesEligibleForOwnershipTransfer(): array
|
||||
{
|
||||
// Только admin может стать новым владельцем
|
||||
return [self::ROLE_ADMIN];
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение уровня роли (для сравнения)
|
||||
*
|
||||
* @param string $role
|
||||
* @return int
|
||||
*/
|
||||
public function getRoleLevel(string $role): int
|
||||
{
|
||||
return self::ROLE_HIERARCHY[$role] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, имеет ли роль достаточно прав для сравнения
|
||||
*
|
||||
* @param string $role
|
||||
* @param string $requiredRole
|
||||
* @return bool
|
||||
*/
|
||||
public function hasRoleLevel(string $role, string $requiredRole): bool
|
||||
{
|
||||
return $this->getRoleLevel($role) >= $this->getRoleLevel($requiredRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение списка доступных ролей для назначения
|
||||
*
|
||||
* @param string $assignerRole Роль назначающего
|
||||
* @return array
|
||||
*/
|
||||
public function getAvailableRolesForAssignment(string $assignerRole): array
|
||||
{
|
||||
$allRoles = [self::ROLE_ADMIN, self::ROLE_MANAGER, self::ROLE_GUEST];
|
||||
|
||||
// Owner может назначать любые роли
|
||||
if ($assignerRole === self::ROLE_OWNER) {
|
||||
return $allRoles;
|
||||
}
|
||||
|
||||
// Admin не может назначать owner
|
||||
if ($assignerRole === self::ROLE_ADMIN) {
|
||||
return [self::ROLE_ADMIN, self::ROLE_MANAGER, self::ROLE_GUEST];
|
||||
}
|
||||
|
||||
// Manager и Guest не могут назначать роли
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Сброс кэша membership (при переключении организации)
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function resetCache(): void
|
||||
{
|
||||
$this->currentMembership = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение текстового названия роли
|
||||
*
|
||||
* @param string $role
|
||||
* @return string
|
||||
*/
|
||||
public function getRoleLabel(string $role): string
|
||||
{
|
||||
$labels = [
|
||||
self::ROLE_OWNER => 'Владелец',
|
||||
self::ROLE_ADMIN => 'Администратор',
|
||||
self::ROLE_MANAGER => 'Менеджер',
|
||||
self::ROLE_GUEST => 'Гость',
|
||||
];
|
||||
|
||||
return $labels[$role] ?? $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение всех ролей с описаниями
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getAllRoles(): array
|
||||
{
|
||||
return [
|
||||
self::ROLE_OWNER => [
|
||||
'label' => 'Владелец',
|
||||
'description' => 'Полный доступ к организации',
|
||||
'level' => 100,
|
||||
],
|
||||
self::ROLE_ADMIN => [
|
||||
'label' => 'Администратор',
|
||||
'description' => 'Управление пользователями и модулями',
|
||||
'level' => 75,
|
||||
],
|
||||
self::ROLE_MANAGER => [
|
||||
'label' => 'Менеджер',
|
||||
'description' => 'Полный доступ к функционалу модулей',
|
||||
'level' => 50,
|
||||
],
|
||||
self::ROLE_GUEST => [
|
||||
'label' => 'Гость',
|
||||
'description' => 'Только просмотр данных',
|
||||
'level' => 25,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\OrganizationUserModel;
|
||||
use App\Models\OrganizationModel;
|
||||
use App\Models\UserModel;
|
||||
use Config\Services;
|
||||
|
||||
class InvitationService
|
||||
{
|
||||
protected OrganizationUserModel $orgUserModel;
|
||||
protected OrganizationModel $orgModel;
|
||||
protected UserModel $userModel;
|
||||
protected string $baseUrl;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->orgUserModel = new OrganizationUserModel();
|
||||
$this->orgModel = new OrganizationModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->baseUrl = rtrim(config('App')->baseURL, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание приглашения в организацию
|
||||
*
|
||||
* @param int $organizationId ID организации
|
||||
* @param string $email Email приглашаемого
|
||||
* @param string $role Роль (admin, manager, guest)
|
||||
* @param int $invitedBy ID пользователя, отправляющего приглашение
|
||||
* @return array ['success' => bool, 'message' => string, 'invite_link' => string, 'invitation_id' => int]
|
||||
*/
|
||||
public function createInvitation(int $organizationId, string $email, string $role, int $invitedBy): array
|
||||
{
|
||||
// Валидация email
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Некорректный email адрес',
|
||||
'invite_link' => '',
|
||||
'invitation_id' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Получаем организацию
|
||||
$organization = $this->orgModel->find($organizationId);
|
||||
if (!$organization) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Организация не найдена',
|
||||
'invite_link' => '',
|
||||
'invitation_id' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Проверяем, существует ли пользователь
|
||||
$existingUser = $this->userModel->where('email', $email)->first();
|
||||
$userId = $existingUser['id'] ?? null;
|
||||
|
||||
// Проверяем, не состоит ли уже пользователь в организации
|
||||
if ($userId) {
|
||||
$existingMembership = $this->orgUserModel
|
||||
->where('organization_id', $organizationId)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if ($existingMembership) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Пользователь уже состоит в этой организации',
|
||||
'invite_link' => '',
|
||||
'invitation_id' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Проверяем, есть ли уже приглашение
|
||||
if ($this->orgUserModel->hasPendingInvite($organizationId, $userId)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Приглашение для этого пользователя уже отправлено',
|
||||
'invite_link' => '',
|
||||
'invitation_id' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Генерируем токен приглашения
|
||||
$inviteToken = $this->generateToken();
|
||||
|
||||
// Создаем запись приглашения
|
||||
$invitationData = [
|
||||
'organization_id' => $organizationId,
|
||||
'user_id' => $userId, // NULL для новых пользователей
|
||||
'role' => $role,
|
||||
'status' => OrganizationUserModel::STATUS_PENDING,
|
||||
'invite_token' => $inviteToken,
|
||||
'invited_by' => $invitedBy,
|
||||
];
|
||||
|
||||
$invitationId = $this->orgUserModel->createInvitation($invitationData);
|
||||
|
||||
if (!$invitationId) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Ошибка при создании приглашения',
|
||||
'invite_link' => '',
|
||||
'invitation_id' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Если пользователь новый - создаем "теневую" запись в users
|
||||
if (!$existingUser) {
|
||||
$this->createShadowUser($email);
|
||||
}
|
||||
|
||||
// Формируем ссылку приглашения
|
||||
$inviteLink = $this->baseUrl . '/invitation/accept/' . $inviteToken;
|
||||
|
||||
// Отправляем email
|
||||
$emailSent = $this->sendInvitationEmail($email, $organization['name'], $role, $inviteLink);
|
||||
|
||||
return [
|
||||
'success' => $emailSent,
|
||||
'message' => $emailSent
|
||||
? 'Приглашение успешно отправлено'
|
||||
: 'Приглашение создано, но не удалось отправить email',
|
||||
'invite_link' => $inviteLink,
|
||||
'invitation_id' => $invitationId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Принятие приглашения
|
||||
*/
|
||||
public function acceptInvitation(string $token, int $userId): array
|
||||
{
|
||||
$invitation = $this->orgUserModel->findByInviteToken($token);
|
||||
|
||||
if (!$invitation) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Приглашение не найдено или уже обработано',
|
||||
];
|
||||
}
|
||||
|
||||
// Обновляем приглашение
|
||||
$updated = $this->orgUserModel->acceptInvitation($invitation['id'], $userId);
|
||||
|
||||
if (!$updated) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Ошибка при принятии приглашения',
|
||||
];
|
||||
}
|
||||
|
||||
// Если это был теневой пользователь - привязываем к реальному
|
||||
if ($invitation['user_id'] === null) {
|
||||
$this->bindShadowUser($invitation['organization_id'], $userId);
|
||||
}
|
||||
|
||||
// Получаем организацию для редиректа
|
||||
$organization = $this->orgModel->find($invitation['organization_id']);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Приглашение принято',
|
||||
'organization_id' => $invitation['organization_id'],
|
||||
'organization_name' => $organization['name'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Отклонение приглашения
|
||||
*/
|
||||
public function declineInvitation(string $token): array
|
||||
{
|
||||
$invitation = $this->orgUserModel->findByInviteToken($token);
|
||||
|
||||
if (!$invitation) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Приглашение не найдено или уже обработано',
|
||||
];
|
||||
}
|
||||
|
||||
$deleted = $this->orgUserModel->declineInvitation($invitation['id']);
|
||||
|
||||
return [
|
||||
'success' => $deleted,
|
||||
'message' => $deleted ? 'Приглашение отклонено' : 'Ошибка при отклонении приглашения',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Отзыв приглашения (отправителем)
|
||||
*/
|
||||
public function cancelInvitation(int $invitationId, int $organizationId): array
|
||||
{
|
||||
$invitation = $this->orgUserModel
|
||||
->where('id', $invitationId)
|
||||
->where('organization_id', $organizationId)
|
||||
->where('status', OrganizationUserModel::STATUS_PENDING)
|
||||
->first();
|
||||
|
||||
if (!$invitation) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Приглашение не найдено',
|
||||
];
|
||||
}
|
||||
|
||||
$deleted = $this->orgUserModel->cancelInvitation($invitationId);
|
||||
|
||||
return [
|
||||
'success' => $deleted,
|
||||
'message' => $deleted ? 'Приглашение отозвано' : 'Ошибка при отзыве приглашения',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторная отправка приглашения
|
||||
*/
|
||||
public function resendInvitation(int $invitationId, int $organizationId): array
|
||||
{
|
||||
$invitation = $this->orgUserModel
|
||||
->where('id', $invitationId)
|
||||
->where('organization_id', $organizationId)
|
||||
->where('status', OrganizationUserModel::STATUS_PENDING)
|
||||
->first();
|
||||
|
||||
if (!$invitation) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Приглашение не найдено',
|
||||
];
|
||||
}
|
||||
|
||||
// Генерируем новый токен
|
||||
$newToken = $this->generateToken();
|
||||
$this->orgUserModel->update($invitationId, [
|
||||
'invite_token' => $newToken,
|
||||
'invited_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
// Получаем email пользователя
|
||||
$user = $this->userModel->find($invitation['user_id']);
|
||||
if (!$user) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Пользователь не найден',
|
||||
];
|
||||
}
|
||||
|
||||
// Формируем ссылку
|
||||
$organization = $this->orgModel->find($organizationId);
|
||||
$inviteLink = $this->baseUrl . '/invitation/accept/' . $newToken;
|
||||
|
||||
// Отправляем email
|
||||
$sent = $this->sendInvitationEmail(
|
||||
$user['email'],
|
||||
$organization['name'],
|
||||
$invitation['role'],
|
||||
$inviteLink
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => $sent,
|
||||
'message' => $sent ? 'Приглашение отправлено повторно' : 'Ошибка отправки',
|
||||
'invite_link' => $inviteLink,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация уникального токена
|
||||
*/
|
||||
protected function generateToken(): string
|
||||
{
|
||||
do {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$exists = $this->orgUserModel->where('invite_token', $token)->first();
|
||||
} while ($exists);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание "теневого" пользователя для новых приглашенных
|
||||
*/
|
||||
protected function createShadowUser(string $email): int
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
|
||||
return $this->userModel->insert([
|
||||
'email' => $email,
|
||||
'name' => '', // Заполнится при регистрации
|
||||
'password' => null, // Без пароля до регистрации
|
||||
'email_verified' => 0,
|
||||
'verification_token' => $token,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Привязка теневого пользователя к реальному
|
||||
*/
|
||||
protected function bindShadowUser(int $organizationId, int $userId): void
|
||||
{
|
||||
// Находим теневую запись по email пользователя
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if ($user && empty($user['password'])) {
|
||||
// Обновляем все pending приглашения этого пользователя
|
||||
$this->orgUserModel
|
||||
->where('user_id', null)
|
||||
->where('status', OrganizationUserModel::STATUS_PENDING)
|
||||
->set(['user_id' => $userId])
|
||||
->update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправка email с приглашением
|
||||
*/
|
||||
protected function sendInvitationEmail(string $email, string $orgName, string $role, string $inviteLink): bool
|
||||
{
|
||||
$roleLabels = [
|
||||
'owner' => 'Владелец',
|
||||
'admin' => 'Администратор',
|
||||
'manager' => 'Менеджер',
|
||||
'guest' => 'Гость',
|
||||
];
|
||||
|
||||
$roleLabel = $roleLabels[$role] ?? $role;
|
||||
|
||||
$emailService = service('email');
|
||||
$emailService->setTo($email);
|
||||
$emailService->setSubject('Приглашение в организацию ' . $orgName);
|
||||
|
||||
$message = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #4F46E5; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
|
||||
.content { background: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }
|
||||
.role-badge { display: inline-block; background: #EEF2FF; color: #4F46E5; padding: 4px 12px; border-radius: 20px; font-size: 14px; }
|
||||
.button { display: inline-block; background: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #6b7280; font-size: 12px; padding: 20px; }
|
||||
.invite-link { word-break: break-all; font-size: 12px; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Приглашение в Бизнес.Точка</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Вас приглашают присоединиться к организации <strong>{$orgName}</strong></p>
|
||||
<p>Ваша роль: <span class="role-badge">{$roleLabel}</span></p>
|
||||
<p>Нажмите кнопку ниже, чтобы принять или отклонить приглашение:</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="{$inviteLink}" class="button">Принять приглашение</a>
|
||||
</p>
|
||||
<p>Если кнопка не работает, скопируйте ссылку и откройте в браузере:</p>
|
||||
<p class="invite-link">{$inviteLink}</p>
|
||||
<p>Ссылка действительна 48 часов.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© Бизнес.Точка</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
$emailService->setMessage($message);
|
||||
return $emailService->send();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use CodeIgniter\Config\Services as BaseServices;
|
||||
use Redis;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* RateLimitService - Сервис для ограничения частоты запросов и защиты от брутфорса
|
||||
*
|
||||
* Использует Redis для хранения счётчиков попыток и блокировок.
|
||||
* Все ограничения применяются по IP-адресу клиента.
|
||||
*
|
||||
* @property \Redis $redis
|
||||
*/
|
||||
class RateLimitService
|
||||
{
|
||||
/**
|
||||
* @var \Redis
|
||||
*/
|
||||
private $redis;
|
||||
|
||||
private string $prefix;
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* Конструктор сервиса
|
||||
*
|
||||
* @param \Redis $redis Экземпляр Redis-подключения
|
||||
* @param string $prefix Префикс для всех ключей в Redis
|
||||
* @param array $config Конфигурация ограничений
|
||||
*/
|
||||
public function __construct(\Redis $redis, string $prefix = 'rl:', array $config = [])
|
||||
{
|
||||
$this->redis = $redis;
|
||||
$this->prefix = $prefix;
|
||||
|
||||
$this->config = array_merge([
|
||||
// Авторизация
|
||||
'auth_login_attempts' => 5,
|
||||
'auth_login_window' => 900, // 15 минут в секундах
|
||||
'auth_login_block' => 900, // Блокировка на 15 минут
|
||||
|
||||
'auth_register_attempts' => 10,
|
||||
'auth_register_window' => 3600, // 1 час
|
||||
'auth_register_block' => 3600, // Блокировка на 1 час
|
||||
|
||||
'auth_reset_attempts' => 5,
|
||||
'auth_reset_window' => 900, // 15 минут
|
||||
'auth_reset_block' => 900, // Блокировка на 15 минут
|
||||
|
||||
// Общие API лимиты (для модулей)
|
||||
'api_read_attempts' => 100,
|
||||
'api_read_window' => 60, // 1 минута
|
||||
|
||||
'api_write_attempts' => 30,
|
||||
'api_write_window' => 60, // 1 минута
|
||||
], $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Статический фабричный метод для создания сервиса
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function getInstance(): self
|
||||
{
|
||||
$config = self::getConfig();
|
||||
$redis = self::getRedisConnection();
|
||||
|
||||
$prefix = $config['prefix'] ?? 'rl:';
|
||||
|
||||
return new self($redis, $prefix, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение конфигурации из .env
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function getConfig(): array
|
||||
{
|
||||
return [
|
||||
'prefix' => env('rate_limit.prefix', 'rl:'),
|
||||
|
||||
// Авторизация - логин
|
||||
'auth_login_attempts' => (int) env('rate_limit.auth.login.attempts', 5),
|
||||
'auth_login_window' => (int) env('rate_limit.auth.login.window', 900),
|
||||
'auth_login_block' => (int) env('rate_limit.auth.login.block', 900),
|
||||
|
||||
// Авторизация - регистрация
|
||||
'auth_register_attempts' => (int) env('rate_limit.auth.register.attempts', 10),
|
||||
'auth_register_window' => (int) env('rate_limit.auth.register.window', 3600),
|
||||
'auth_register_block' => (int) env('rate_limit.auth.register.block', 3600),
|
||||
|
||||
// Авторизация - восстановление пароля
|
||||
'auth_reset_attempts' => (int) env('rate_limit.auth.reset.attempts', 5),
|
||||
'auth_reset_window' => (int) env('rate_limit.auth.reset.window', 900),
|
||||
'auth_reset_block' => (int) env('rate_limit.auth.reset.block', 900),
|
||||
|
||||
// API - чтение
|
||||
'api_read_attempts' => (int) env('rate_limit.api.read.attempts', 100),
|
||||
'api_read_window' => (int) env('rate_limit.api.read.window', 60),
|
||||
|
||||
// API - запись
|
||||
'api_write_attempts' => (int) env('rate_limit.api.write.attempts', 30),
|
||||
'api_write_window' => (int) env('rate_limit.api.write.window', 60),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключение к Redis
|
||||
*
|
||||
* @return \Redis
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private static function getRedisConnection(): \Redis
|
||||
{
|
||||
/** @var \Redis $redis */
|
||||
$redis = new \Redis();
|
||||
|
||||
$host = env('redis.host', '127.0.0.1');
|
||||
$port = (int) env('redis.port', 6379);
|
||||
$password = env('redis.password', '');
|
||||
$database = (int) env('redis.database', 0);
|
||||
$timeout = (float) env('redis.timeout', 2.0);
|
||||
$readTimeout = (float) env('redis.read_timeout', 60.0);
|
||||
|
||||
if (!$redis->connect($host, $port, $timeout)) {
|
||||
throw new RuntimeException("Не удалось подключиться к Redis ({$host}:{$port})");
|
||||
}
|
||||
|
||||
if (!empty($password)) {
|
||||
if (!$redis->auth($password)) {
|
||||
throw new RuntimeException('Ошибка аутентификации в Redis');
|
||||
}
|
||||
}
|
||||
|
||||
$redis->select($database);
|
||||
$redis->setOption(\Redis::OPT_READ_TIMEOUT, $readTimeout);
|
||||
|
||||
return $redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение IP-адреса клиента
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getClientIp(): string
|
||||
{
|
||||
$ip = service('request')->getIPAddress();
|
||||
|
||||
// Если это CLI-запрос или IP не определён - используем fallback
|
||||
if (empty($ip) || $ip === '0.0.0.0') {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
|
||||
return $ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация ключа для Redis
|
||||
*
|
||||
* @param string $type Тип ограничения (login, register, reset)
|
||||
* @param string $suffix Дополнительный суффикс
|
||||
* @return string
|
||||
*/
|
||||
private function getKey(string $type, string $suffix = ''): string
|
||||
{
|
||||
$ip = $this->getClientIp();
|
||||
$key = "{$this->prefix}{$type}:{$ip}";
|
||||
|
||||
if (!empty($suffix)) {
|
||||
$key .= ":{$suffix}";
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка на блокировку
|
||||
*
|
||||
* @param string $type Тип блокировки (login, register, reset)
|
||||
* @return bool
|
||||
*/
|
||||
public function isBlocked(string $type): bool
|
||||
{
|
||||
$blockKey = $this->getKey($type, 'block');
|
||||
return (bool) $this->redis->exists($blockKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение оставшегося времени блокировки в секундах
|
||||
*
|
||||
* @param string $type Тип блокировки
|
||||
* @return int
|
||||
*/
|
||||
public function getBlockTimeLeft(string $type): int
|
||||
{
|
||||
$blockKey = $this->getKey($type, 'block');
|
||||
$ttl = $this->redis->ttl($blockKey);
|
||||
|
||||
return max(0, $ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка и инкремент счётчика попыток
|
||||
*
|
||||
* @param string $type Тип действия (login, register, reset)
|
||||
* @return array ['allowed' => bool, 'attempts' => int, 'limit' => int, 'remaining' => int]
|
||||
*/
|
||||
public function checkAttempt(string $type): array
|
||||
{
|
||||
// Если заблокирован - сразу возвращаем запрет
|
||||
if ($this->isBlocked($type)) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'attempts' => 0,
|
||||
'limit' => $this->config["auth_{$type}_attempts"] ?? 0,
|
||||
'remaining' => 0,
|
||||
'blocked' => true,
|
||||
'block_ttl' => $this->getBlockTimeLeft($type),
|
||||
];
|
||||
}
|
||||
|
||||
$attemptsKey = $this->getKey($type, 'attempts');
|
||||
$window = $this->config["auth_{$type}_window"] ?? 900;
|
||||
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
||||
|
||||
// Получаем текущее количество попыток
|
||||
$currentAttempts = (int) $this->redis->get($attemptsKey);
|
||||
$remaining = max(0, $maxAttempts - $currentAttempts);
|
||||
|
||||
// Проверяем, не превышен ли лимит
|
||||
if ($currentAttempts >= $maxAttempts) {
|
||||
// Устанавливаем блокировку
|
||||
$blockTtl = $this->config["auth_{$type}_block"] ?? $window;
|
||||
$blockKey = $this->getKey($type, 'block');
|
||||
$this->redis->setex($blockKey, $blockTtl, '1');
|
||||
|
||||
return [
|
||||
'allowed' => false,
|
||||
'attempts' => $currentAttempts,
|
||||
'limit' => $maxAttempts,
|
||||
'remaining' => 0,
|
||||
'blocked' => true,
|
||||
'block_ttl' => $blockTtl,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'allowed' => true,
|
||||
'attempts' => $currentAttempts,
|
||||
'limit' => $maxAttempts,
|
||||
'remaining' => $remaining,
|
||||
'blocked' => false,
|
||||
'block_ttl' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Регистрация неудачной попытки
|
||||
*
|
||||
* @param string $type Тип действия
|
||||
* @return array Результат после инкремента
|
||||
*/
|
||||
public function recordFailedAttempt(string $type): array
|
||||
{
|
||||
$attemptsKey = $this->getKey($type, 'attempts');
|
||||
$window = $this->config["auth_{$type}_window"] ?? 900;
|
||||
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
||||
|
||||
// Инкрементируем счётчик
|
||||
$attempts = $this->redis->incr($attemptsKey);
|
||||
|
||||
// Устанавливаем TTL только при первой попытке
|
||||
if ($attempts === 1) {
|
||||
$this->redis->expire($attemptsKey, $window);
|
||||
}
|
||||
|
||||
// Проверяем, не превышен ли лимит
|
||||
if ($attempts >= $maxAttempts) {
|
||||
// Устанавливаем блокировку
|
||||
$blockTtl = $this->config["auth_{$type}_block"] ?? $window;
|
||||
$blockKey = $this->getKey($type, 'block');
|
||||
$this->redis->setex($blockKey, $blockTtl, '1');
|
||||
|
||||
return [
|
||||
'allowed' => false,
|
||||
'attempts' => $attempts,
|
||||
'limit' => $maxAttempts,
|
||||
'remaining' => 0,
|
||||
'blocked' => true,
|
||||
'block_ttl' => $blockTtl,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'allowed' => true,
|
||||
'attempts' => $attempts,
|
||||
'limit' => $maxAttempts,
|
||||
'remaining' => $maxAttempts - $attempts,
|
||||
'blocked' => false,
|
||||
'block_ttl' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Сброс счётчика после успешного действия
|
||||
*
|
||||
* @param string $type Тип действия
|
||||
* @return void
|
||||
*/
|
||||
public function resetAttempts(string $type): void
|
||||
{
|
||||
$attemptsKey = $this->getKey($type, 'attempts');
|
||||
$this->redis->del($attemptsKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* API rate limiting - проверка лимита на чтение
|
||||
*
|
||||
* @return array ['allowed' => bool, 'remaining' => int, 'reset' => int]
|
||||
*/
|
||||
public function checkApiReadLimit(): array
|
||||
{
|
||||
return $this->checkApiLimit('read');
|
||||
}
|
||||
|
||||
/**
|
||||
* API rate limiting - проверка лимита на запись
|
||||
*
|
||||
* @return array ['allowed' => bool, 'remaining' => int, 'reset' => int]
|
||||
*/
|
||||
public function checkApiWriteLimit(): array
|
||||
{
|
||||
return $this->checkApiLimit('write');
|
||||
}
|
||||
|
||||
/**
|
||||
* Внутренний метод для проверки API лимитов
|
||||
*
|
||||
* @param string $type read или write
|
||||
* @return array
|
||||
*/
|
||||
private function checkApiLimit(string $type): array
|
||||
{
|
||||
$key = $this->getKey("api_{$type}");
|
||||
$maxAttempts = $this->config["api_{$type}_attempts"] ?? 60;
|
||||
$window = $this->config["api_{$type}_window"] ?? 60;
|
||||
|
||||
$current = (int) $this->redis->get($key);
|
||||
$ttl = $this->redis->ttl($key);
|
||||
|
||||
// Если ключ не существует или истёк - создаём новый
|
||||
if ($ttl < 0) {
|
||||
$this->redis->setex($key, $window, 1);
|
||||
return [
|
||||
'allowed' => true,
|
||||
'remaining' => $maxAttempts - 1,
|
||||
'reset' => $window,
|
||||
];
|
||||
}
|
||||
|
||||
if ($current >= $maxAttempts) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'remaining' => 0,
|
||||
'reset' => max(0, $ttl),
|
||||
];
|
||||
}
|
||||
|
||||
$this->redis->incr($key);
|
||||
|
||||
return [
|
||||
'allowed' => true,
|
||||
'remaining' => $maxAttempts - $current - 1,
|
||||
'reset' => max(0, $ttl),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение статуса rate limiting для отладки
|
||||
*
|
||||
* @param string $type Тип действия
|
||||
* @return array
|
||||
*/
|
||||
public function getStatus(string $type): array
|
||||
{
|
||||
$attemptsKey = $this->getKey($type, 'attempts');
|
||||
$blockKey = $this->getKey($type, 'block');
|
||||
|
||||
$attempts = (int) $this->redis->get($attemptsKey);
|
||||
$attemptsTtl = $this->redis->ttl($attemptsKey);
|
||||
$isBlocked = $this->redis->exists($blockKey);
|
||||
$blockTtl = $isBlocked ? $this->redis->ttl($blockKey) : 0;
|
||||
|
||||
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
||||
$window = $this->config["auth_{$type}_window"] ?? 900;
|
||||
|
||||
return [
|
||||
'ip' => $this->getClientIp(),
|
||||
'type' => $type,
|
||||
'attempts' => $attempts,
|
||||
'attempts_ttl' => max(0, $attemptsTtl),
|
||||
'limit' => $maxAttempts,
|
||||
'window' => $window,
|
||||
'is_blocked' => $isBlocked,
|
||||
'block_ttl' => max(0, $blockTtl),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка подключения к Redis
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isConnected(): bool
|
||||
{
|
||||
try {
|
||||
return $this->redis->ping() === true || $this->redis->ping() === '+PONG';
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user