| @copyright 2007-2026 (c) AVE.cms | @link https://ave-cms.ru | @version 3.3 */ namespace App\Common; defined("BASEPATH") || die('Direct access to this location is not allowed.'); use DB; use App\Helpers\Request; use App\Common\Auth\IdentityLinker; use App\Common\Auth\PublicUserProvider; class Auth { const DUMMY_PASSWORD_HASH = '$2y$12$CPSYMCBc.us9zmv1TxUrmuc6r8fEQzxQwT2f93rwzGGyZpW92RKBq'; /** * Кеш пользователей нового table-based API в рамках запроса. * * @var array */ protected static $currentUsers = []; protected static $systemRolePermissions = []; protected function __construct() { // } // ------------------------------------------------------------------ // // Системная авторизация публичного сайта // ------------------------------------------------------------------ // public static function publicCheck() { return PublicUserProvider::check(); } public static function publicBootstrap() { if (!PublicUserProvider::check()) { $systemUser = self::systemUser(); if (!$systemUser) { $systemUser = self::user(); } $publicUser = $systemUser ? IdentityLinker::publicForSystem($systemUser) : null; if ($systemUser && !$publicUser) { $publicId = IdentityLinker::ensurePublicForSystem((int) $systemUser['id']); if ($publicId > 0) { PublicUserProvider::syncById($publicId); } } if ($publicUser && (string) $publicUser['status'] === '1') { PublicUserProvider::syncById((int) $publicUser['Id']); } } return PublicUserProvider::bootstrap(); } public static function publicUser() { return PublicUserProvider::user(); } public static function publicAttempt($identifier, $password, $remember = false) { return PublicUserProvider::attempt($identifier, $password, $remember); } public static function publicLoginById($userId, $remember = false) { return PublicUserProvider::loginById($userId, $remember); } public static function publicLogout() { PublicUserProvider::logout(); } public static function publicRegistrationEnabled() { return PublicUserProvider::registrationEnabled(); } // ------------------------------------------------------------------ // // Новый универсальный Auth API для схемы: // {{prefix}}_users + {{prefix}}_users_session // ------------------------------------------------------------------ // /** * Попытка входа по логину/email + пароль. * * Не зависит от Marketplace: таблицы, поля, cookie и session key задаются * через options. По умолчанию рассчитано на актуальную схему: * users.email/password_hash/role/is_active и users_session.token_hash. * * Возвращает массив пользователя при успехе, иначе: * 1 — пустой логин/пароль * 2 — неверная пара * 3 — пользователь отключён */ public static function attempt($identifier, $password, array $options = []) { $options = self::modernAuthOptions($options); $identifier = trim((string) $identifier); if ($identifier === '' || $password === '') { return 1; } $where = []; $args = []; foreach ($options['identifier_fields'] as $field) { $where[] = $field . ' = %s'; $args[] = $identifier; } $sql = "SELECT * FROM " . $options['users_table'] . " WHERE (" . implode(' OR ', $where) . ") LIMIT 1"; $rows = call_user_func_array([DB::class, 'query'], array_merge([$sql], $args))->getAll(); $user = $rows ? (array) $rows[0] : null; $storedHash = $user && !empty($user[$options['password_field']]) ? (string) $user[$options['password_field']] : self::DUMMY_PASSWORD_HASH; $passwordValid = password_verify($password, $storedHash); if (!$user || empty($user[$options['password_field']]) || !$passwordValid) { return 2; } if ($options['active_field'] !== '' && (int) $user[$options['active_field']] !== (int) $options['active_value']) { return 3; } if (password_needs_rehash($user[$options['password_field']], PASSWORD_BCRYPT, ['cost' => $options['bcrypt_cost']])) { $newHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => $options['bcrypt_cost']]); DB::Update($options['users_table'], [$options['password_field'] => $newHash], "id = %i", $user['id']); $user[$options['password_field']] = $newHash; } self::login($user, $options); return $user; } /** * Установить пользователя как авторизованного для нового Auth API. */ public static function login(array $user, array $options = []) { $options = self::modernAuthOptions($options); $cacheKey = self::modernAuthCacheKey($options); if (!empty($options['regenerate_session'])) { Session::regenerateId(!empty($options['delete_old_session'])); } Session::set($options['session_key'], (int) $user['id']); self::$currentUsers[$cacheKey] = $user; if (!empty($options['load_permissions']) && !empty($options['role_field']) && isset($user[$options['role_field']])) { Permission::setForRole($user[$options['role_field']]); } if (!empty($options['remember']) || !empty($options['issue_session_token'])) { self::modernIssueRememberToken((int) $user['id'], $options, !empty($options['remember'])); } } /** * Гарантирует общий browser-token для уже открытой системной сессии. * Нужен, когда разные части приложения используют разные PHP session handlers. */ public static function ensureBrowserToken(array $options = []) { $options = self::modernAuthOptions($options); $user = self::user($options); if (!$user) { return false; } $token = isset($_COOKIE[$options['cookie_name']]) ? (string) $_COOKIE[$options['cookie_name']] : ''; if (preg_match('/^[a-f0-9]{64}$/', $token)) { $ownerId = (int) DB::query( "SELECT user_id FROM " . $options['session_table'] . " WHERE token_hash = %s LIMIT 1", hash('sha256', $token) )->getOne(); if ($ownerId === (int) $user['id']) { return true; } } self::modernIssueRememberToken((int) $user['id'], $options, false); return true; } /** Системная личность, доступная и в публичном runtime через общий token. */ public static function systemUser() { $options = self::modernAuthOptions(array( 'users_table' => SystemTables::table('users'), 'session_table' => SystemTables::table('users_session'), 'load_permissions' => false, 'regenerate_session' => false, 'regenerate_session_on_logout' => false, )); $cacheKey = 'system-token|' . $options['cookie_name']; if (array_key_exists($cacheKey, self::$currentUsers)) { return self::$currentUsers[$cacheKey] ?: null; } $id = self::modernRestoreFromCookie($options, false); if ($id <= 0) { $linkedUser = self::linkedPublicSystemUser($options); if (!$linkedUser) { self::$currentUsers[$cacheKey] = false; return null; } self::modernIssueRememberToken((int) $linkedUser['id'], $options, false); self::$currentUsers[$cacheKey] = $linkedUser; return $linkedUser; } $rows = DB::query( "SELECT * FROM " . $options['users_table'] . " WHERE id = %i AND " . $options['active_field'] . " = %i LIMIT 1", $id, (int) $options['active_value'] )->getAll(); self::$currentUsers[$cacheKey] = $rows ? (array) $rows[0] : false; return self::$currentUsers[$cacheKey] ?: null; } protected static function linkedPublicSystemUser(array $options) { $publicUser = self::publicUser(); if (!$publicUser || empty($publicUser['email'])) { return null; } $linked = IdentityLinker::systemForPublic((int) $publicUser['id'], (string) $publicUser['email']); if ($linked && (int) $linked[$options['active_field']] === (int) $options['active_value']) { return $linked; } $config = PublicConfiguration::all(); $groups = isset($config['system_identity_public_groups']) && is_array($config['system_identity_public_groups']) ? array_map('intval', $config['system_identity_public_groups']) : array(); if (!in_array((int) $publicUser['group'], $groups, true)) { return null; } $rows = DB::query( 'SELECT * FROM ' . $options['users_table'] . ' WHERE email = %s AND ' . $options['active_field'] . ' = %i LIMIT 1', (string) $publicUser['email'], (int) $options['active_value'] )->getAll(); return $rows ? (array) $rows[0] : null; } public static function systemUserCan($permission) { $user = self::systemUser(); if (!$user || empty($user['role'])) { return false; } $role = (string) $user['role']; if ($role === 'admin') { return true; } return in_array((string) $permission, self::systemPermissionsForRole($role), true); } protected static function systemPermissionsForRole($role) { $role = trim((string) $role); if ($role === '') { return array(); } if (isset(self::$systemRolePermissions[$role])) { return self::$systemRolePermissions[$role]; } try { $rows = DB::query( 'SELECT rp.permission_code FROM ' . SystemTables::table('roles') . ' r' . ' JOIN ' . SystemTables::table('role_permissions') . ' rp ON rp.role_id = r.id' . ' WHERE r.code = %s ORDER BY rp.permission_code ASC', $role )->getAll(); } catch (\Throwable $e) { $rows = array(); } self::$systemRolePermissions[$role] = array(); foreach ($rows ?: array() as $row) { $row = (array) $row; if (!empty($row['permission_code'])) { self::$systemRolePermissions[$role][] = (string) $row['permission_code']; } } return self::$systemRolePermissions[$role]; } /** * Завершить вход для нового Auth API. */ public static function logout(array $options = []) { $options = self::modernAuthOptions($options); $cacheKey = self::modernAuthCacheKey($options); if (!empty($_COOKIE[$options['cookie_name']])) { DB::Delete( $options['session_table'], "token_hash = %s", hash('sha256', (string) $_COOKIE[$options['cookie_name']]) ); } self::modernForgetCookie($options); Session::del($options['session_key']); if (!empty($options['load_permissions'])) { Session::del('permissions'); } self::$currentUsers[$cacheKey] = false; if (!empty($options['regenerate_session_on_logout'])) { Session::regenerateId(!empty($options['delete_old_session'])); } } public static function check(array $options = []) { return self::user($options) !== null; } /** * Текущий пользователь нового Auth API как массив или null. */ public static function user(array $options = []) { $options = self::modernAuthOptions($options); $cacheKey = self::modernAuthCacheKey($options); if (array_key_exists($cacheKey, self::$currentUsers)) { return self::$currentUsers[$cacheKey] ?: null; } $id = (int) Session::get($options['session_key']); if ($id <= 0) { $id = self::modernRestoreFromCookie($options); } if ($id <= 0) { self::$currentUsers[$cacheKey] = false; return null; } $sql = "SELECT * FROM " . $options['users_table'] . " WHERE id = %i"; $args = [$sql, $id]; if ($options['active_field'] !== '') { $sql .= " AND " . $options['active_field'] . " = %i"; $args = [$sql, $id, (int) $options['active_value']]; } $sql .= " LIMIT 1"; $args[0] = $sql; $rows = call_user_func_array([DB::class, 'query'], $args)->getAll(); self::$currentUsers[$cacheKey] = $rows ? (array) $rows[0] : false; if (self::$currentUsers[$cacheKey] && !empty($options['load_permissions']) && !empty($options['role_field'])) { $user = self::$currentUsers[$cacheKey]; if (isset($user[$options['role_field']])) { Permission::setForRole($user[$options['role_field']]); } } return self::$currentUsers[$cacheKey] ?: null; } public static function id(array $options = []) { $user = self::user($options); return $user ? (int) $user['id'] : 0; } public static function role(array $options = []) { $options = self::modernAuthOptions($options); $user = self::user($options); return ($user && isset($user[$options['role_field']])) ? (string) $user[$options['role_field']] : ''; } public static function is($role, array $options = []) { return self::role($options) === (string) $role; } protected static function modernAuthOptions(array $options) { return array_merge([ 'users_table' => SystemTables::table('users'), 'session_table' => SystemTables::table('users_session'), 'session_key' => 'auth_user_id', 'cookie_name' => 'auth_token', 'cookie_path' => defined('ABS_PATH') && ABS_PATH !== '' ? ABS_PATH : '/', 'cookie_domain' => '', 'token_ttl' => defined('COOKIE_LIFETIME') ? COOKIE_LIFETIME : 1209600, 'identifier_fields' => ['email'], 'password_field' => 'password_hash', 'active_field' => 'is_active', 'active_value' => 1, 'role_field' => 'role', 'bcrypt_cost' => 12, 'remember' => true, 'issue_session_token' => false, 'load_permissions' => true, 'regenerate_session' => true, 'regenerate_session_on_logout' => true, 'delete_old_session' => true, ], $options); } protected static function modernAuthCacheKey(array $options) { return $options['session_key'] . '|' . $options['cookie_name']; } protected static function modernIssueRememberToken($userId, array $options, $persistent = true) { if (headers_sent()) { return; } $token = bin2hex(random_bytes(32)); $now = date('Y-m-d H:i:s'); DB::Insert($options['session_table'], [ 'user_id' => (int) $userId, 'token_hash' => hash('sha256', $token), 'agent' => substr(Request::userAgent(), 0, 255), 'ip' => self::modernClientIp(), 'created_at' => $now, 'last_active' => $now, ]); self::modernSendCookie($token, $persistent ? time() + (int) $options['token_ttl'] : 0, $options); $_COOKIE[$options['cookie_name']] = $token; if (random_int(1, 20) === 1) { DB::Delete( $options['session_table'], "last_active < %s", date('Y-m-d H:i:s', time() - (int) $options['token_ttl']) ); } } protected static function modernRestoreFromCookie(array $options, $persistSession = true) { if (empty($_COOKIE[$options['cookie_name']])) { return 0; } $token = (string) $_COOKIE[$options['cookie_name']]; if (!preg_match('/^[a-f0-9]{64}$/', $token)) { return 0; } $hash = hash('sha256', $token); $userId = (int) DB::query( "SELECT user_id FROM " . $options['session_table'] . " WHERE token_hash = %s LIMIT 1", $hash )->getOne(); if ($userId <= 0) { self::modernForgetCookie($options); return 0; } $sql = "SELECT id FROM " . $options['users_table'] . " WHERE id = %i"; $args = [$sql, $userId]; if ($options['active_field'] !== '') { $sql .= " AND " . $options['active_field'] . " = %i"; $args = [$sql, $userId, (int) $options['active_value']]; } $sql .= " LIMIT 1"; $args[0] = $sql; $ok = (int) call_user_func_array([DB::class, 'query'], $args)->getOne(); if ($ok <= 0) { DB::Delete($options['session_table'], "token_hash = %s", $hash); self::modernForgetCookie($options); return 0; } if ($persistSession) { Session::set($options['session_key'], $userId); } $touchKey = '_auth_token_touch_' . substr($hash, 0, 16); $lastTouch = (int) Session::get($touchKey); if ($lastTouch <= time() - 60) { DB::Update( $options['session_table'], ['last_active' => date('Y-m-d H:i:s'), 'ip' => self::modernClientIp()], "token_hash = %s", $hash ); Session::set($touchKey, time()); } return $userId; } protected static function modernClientIp() { return substr((string) Request::ip(), 0, 45); } protected static function modernForgetCookie(array $options) { unset($_COOKIE[$options['cookie_name']]); if (!headers_sent()) { self::modernSendCookie('', 1, $options); } } protected static function modernSendCookie($value, $expires, array $options) { $secure = Request::isHttps(); setcookie($options['cookie_name'], $value, [ 'expires' => $expires, 'path' => $options['cookie_path'], 'domain' => $options['cookie_domain'], 'secure' => $secure, 'httponly' => true, 'samesite' => 'Lax', ]); } }