96 lines
8.0 KiB
PHP
96 lines
8.0 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace PrelandAdmin;
|
|
final class Auth
|
|
{
|
|
private const REMEMBER_SECONDS=90*86400;
|
|
private static bool $rememberSession=false;
|
|
public function __construct(private Store $store) {}
|
|
public static function session(): void {
|
|
if(session_status()===PHP_SESSION_ACTIVE)return;
|
|
$directory=self::sessionDirectory();
|
|
$rememberDirectory=self::sessionDirectory('remember-sessions',false);
|
|
$incoming=$_COOKIE['preland_admin']??null;
|
|
$validId=is_string($incoming)&&preg_match('/^[a-zA-Z0-9,-]{22,256}$/D',$incoming)===1;
|
|
if(!$validId)unset($_COOKIE['preland_admin']);
|
|
$remember=false;
|
|
if($validId&&$rememberDirectory!==null){
|
|
$candidate=$rememberDirectory.'/sess_'.$incoming;clearstatcache(true,$candidate);
|
|
if(is_link($candidate))throw new Problem('Символические ссылки файлов сессий запрещены.',503);
|
|
$stat=@lstat($candidate);$remember=$stat!==false&&($stat['mode']&0170000)===0100000;
|
|
}
|
|
self::configure($remember?$rememberDirectory:$directory,$remember);
|
|
if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503);
|
|
// Между проверкой SID и session_start файл мог быть отозван logout другого запроса.
|
|
// Новый анонимный SID не должен оставаться в долгоживущем каталоге.
|
|
if($remember&&(session_id()!==$incoming||empty($_SESSION['user'])||!is_int($_SESSION['remember_until']??null))){
|
|
self::destroy(false);self::configure($directory,false);
|
|
if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503);
|
|
}
|
|
}
|
|
private static function configure(string $directory,bool $remember): void {
|
|
if(ini_get('session.save_handler')!=='files'||session_save_path($directory)===false)throw new Problem('Файловое хранилище сессий недоступно.',503);
|
|
ini_set('session.use_strict_mode','1');ini_set('session.use_only_cookies','1');
|
|
ini_set('session.gc_maxlifetime',(string)($remember?self::REMEMBER_SECONDS:3600));ini_set('session.gc_probability','1');ini_set('session.gc_divisor','100');
|
|
session_name('preland_admin');
|
|
session_set_cookie_params(['lifetime'=>$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);}
|
|
}
|