$remember?self::REMEMBER_SECONDS:0]+self::cookieOptions()); self::$rememberSession=$remember; } private static function cookieOptions(): array {return ['path'=>'/','domain'=>(string)ini_get('session.cookie_domain'),'secure'=>getenv('ADMIN_COOKIE_SECURE')!=='0','httponly'=>true,'samesite'=>'Strict'];} private static function destroy(bool $clearCookie): void { $_SESSION=[]; if(session_status()===PHP_SESSION_ACTIVE&&!@session_destroy())throw new Problem('Не удалось отозвать сессию.',503); unset($_COOKIE['preland_admin']);session_id(''); if($clearCookie)setcookie('preland_admin','',['expires'=>time()-3600]+self::cookieOptions()); self::$rememberSession=false; } /** Готовит приватный backing до Store: не создаёт данные в небезопасном state. */ private static function sessionDirectory(string $bucket='sessions',bool $create=true): ?string { $state=rtrim(getenv('ADMIN_STATE_DIR')?:'/var/lib/preland-admin','/'); if($state===''||!str_starts_with($state,'/')||str_contains($state,'\\')||preg_match('/[\x00-\x1f]/',$state))throw new Problem('Для сессий требуется абсолютный приватный путь состояния.',503); $parts=explode('/',substr($state,1)); foreach($parts as $part)if($part===''||$part==='.'||$part==='..')throw new Problem('Некорректный путь приватного состояния сессий.',503); $publicRoots=[dirname(__DIR__).'/public',$_SERVER['DOCUMENT_ROOT']??'']; foreach(explode(',',getenv('ADMIN_LOCAL_ROOTS')?:'') as $root)$publicRoots[]=trim($root); foreach($publicRoots as $root){$root=rtrim($root,'/');if($root!==''&&($state===$root||str_starts_with($state,$root.'/')))throw new Problem('Сессии нельзя хранить в публичном каталоге.',503);} $current=''; foreach([...$parts,$bucket] as $part){ $current.='/'.$part;clearstatcache(true,$current); if(is_link($current))throw new Problem('Символические ссылки в пути сессий запрещены.',503); if(!is_dir($current)){ if(file_exists($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503); if(!$create)return null; if(!@mkdir($current,0700)&&!is_dir($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503); } clearstatcache(true,$current); if(is_link($current)||realpath($current)!==$current)throw new Problem('Небезопасный путь хранилища сессий.',503); } if(!@chmod($current,0700)||!is_readable($current)||!is_writable($current))throw new Problem('Приватное хранилище сессий недоступно.',503); return $current; } public function login(string $username,string $password,string $ip,bool $remember=false): array { $key=hash('sha256',$ip);$this->store->locked('login',function() use($username,$password,$key){ $attempts=$this->store->read('login-attempts.json');$now=time();$entry=$attempts[$key]??['count'=>0,'since'=>$now];if($entry['since']<$now-900)$entry=['count'=>0,'since'=>$now]; if($entry['count']>=10)throw new Problem('Слишком много попыток. Подождите 15 минут.',429); $expected=getenv('ADMIN_USERNAME')?:'';$hash=getenv('ADMIN_PASSWORD_HASH')?:''; if($expected===''||$hash==='')throw new Problem('Администратор ещё не настроен.',503); $valid=password_verify($password,$hash)&&hash_equals($expected,$username); if(!$valid){$entry['count']++;$attempts[$key]=$entry;$attempts=array_filter($attempts,static fn($e)=>$e['since']>$now-900);$this->store->write('login-attempts.json',$attempts);throw new Problem('Неверный логин или пароль.',401);} unset($attempts[$key]);$this->store->write('login-attempts.json',$attempts); }); // Каталог проверяется после пароля, но до уничтожения действующего входа. $directory=self::sessionDirectory($remember?'remember-sessions':'sessions'); self::destroy(false);self::configure($directory,$remember); if(!@session_start())throw new Problem('Не удалось создать новую сессию.',503); $now=time();$_SESSION=['user'=>$username,'csrf'=>bin2hex(random_bytes(32)),'last'=>$now]; if($remember)$_SESSION['remember_until']=$now+self::REMEMBER_SECONDS; return ['user'=>$username,'csrf'=>$_SESSION['csrf']]; } public function requireUser(): string { $now=time();$expired=self::$rememberSession?(!is_int($_SESSION['remember_until']??null)||$_SESSION['remember_until']<=$now):(($_SESSION['last']??0)<$now-3600); if(empty($_SESSION['user'])||$expired){if(self::$rememberSession||!empty($_SESSION['user']))self::destroy(true);else $_SESSION=[];throw new Problem('Войдите в админку.',401);}$_SESSION['last']=$now;return $_SESSION['user']; } public function csrf(string $token): void {if(!$token||!hash_equals($_SESSION['csrf']??'', $token))throw new Problem('Сессия формы истекла. Обновите страницу.',403);} public function logout(): void {self::destroy(true);} }