feat(admin): remember login for 90 days and support REG SSH limits
deploy-preland / deploy zoomstage.online (push) Successful in 57s

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 19:17:51 +03:00
1 parent d04b40519e
commit 522aaf8554
13 files changed
+316 -27

No files matched your search

+51 -10
View File
@@ -3,19 +3,49 @@ 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','3600');ini_set('session.gc_probability','1');ini_set('session.gc_divisor','100');
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'=>0,'path'=>'/','secure'=>getenv('ADMIN_COOKIE_SECURE')!=='0','httponly'=>true,'samesite'=>'Strict']);
if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503);
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 {
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));
@@ -24,17 +54,21 @@ final class Auth
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,'sessions'] as $part){
foreach([...$parts,$bucket] as $part){
$current.='/'.$part;clearstatcache(true,$current);
if(is_link($current))throw new Problem('Символические ссылки в пути сессий запрещены.',503);
if(!is_dir($current)&&!@mkdir($current,0700)&&!is_dir($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): array {
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);
@@ -44,11 +78,18 @@ final class Auth
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);
});
session_regenerate_id(true);$_SESSION=['user'=>$username,'csrf'=>bin2hex(random_bytes(32)),'last'=>time()];return ['user'=>$username,'csrf'=>$_SESSION['csrf']];
// Каталог проверяется после пароля, но до уничтожения действующего входа.
$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 {
if(empty($_SESSION['user'])||($_SESSION['last']??0)<time()-3600){$_SESSION=[];throw new Problem('Войдите в админку.',401);}$_SESSION['last']=time();return $_SESSION['user'];
$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 {$_SESSION=[];session_regenerate_id(true);}
public function logout(): void {self::destroy(true);}
}
+32 -9
View File
@@ -6,6 +6,7 @@ use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader;
final class Transport
{
private const COMMAND_LIMIT=7000;
private ?SSH2 $ssh=null;
public function __construct(private Store $store, public readonly array $target) {}
private function connect(): SSH2 {
@@ -26,23 +27,45 @@ final class Transport
}
else {
$ssh=$this->connect();$code=file_get_contents(__DIR__.'/Remote.php');$code=preg_replace('/^<\?php\s*/','',$code);
// Аргументы проходят shell quoting, обработчик не принимает команды от клиента.
$runner=$code."\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);";
$command='php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg(base64_encode(json_encode($payload,JSON_THROW_ON_ERROR)));
// Сжат только доверенный helper из поставки; payload остаётся JSON-данными.
$entry="\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);";
$command=self::inlineCommand($code,$entry,base64_encode(json_encode($payload,JSON_THROW_ON_ERROR)));
$inbox=null;$sftp=null;
try {
// Большие наборы доставляются SFTP в приватный inbox: argv/ARG_MAX не используется.
if(strlen($command)>60000) {
$sftp=$this->sftp();$parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16);
$this->call(['op'=>'test']);$inbox=$private.'/inbox-'.bin2hex(random_bytes(16));
if(!$sftp->put($inbox,json_encode($payload,JSON_THROW_ON_ERROR)))throw new Problem('Не удалось доставить подготовленный набор.');$sftp->chmod(0600,$inbox);
$runner=$code."\n\$inbox=\$argv[1];try{echo json_encode(\\PrelandAdmin\\Remote::execute(json_decode(file_get_contents(\$inbox),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);}finally{unlink(\$inbox);}";
$command='php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg($inbox);
if(strlen($command)>self::COMMAND_LIMIT) {
if(($payload['op']??null)==='test')throw new Problem('SSH bootstrap превышает безопасный предел команды; сократите helper или путь подключения.');
$parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16);
$candidate=$private.'/inbox-'.bin2hex(random_bytes(16));
// Проверяем конечный bootstrap до рекурсивного preflight: большой helper
// не должен бесконечно пытаться доставить сам себя через inbox.
$command=self::inboxCommand($code,$candidate);
$this->call(['op'=>'test']);$sftp=$this->sftp();$inbox=$candidate;
if(!$sftp->put($inbox,json_encode($payload,JSON_THROW_ON_ERROR))||!$sftp->chmod(0600,$inbox))throw new Problem('Не удалось доставить подготовленный набор.');
}
$timeout=max(1,min(120,(int)(getenv('ADMIN_TRANSPORT_TIMEOUT')?:60)));$ssh->setTimeout($timeout);
$output=$ssh->exec($command);$result=is_string($output)?json_decode($output,true):null;
if($ssh->isTimeout()||!is_array($result)) { $this->ssh=null;throw new Problem('Неизвестный результат: соединение прервано. Перечитайте фактическое состояние.',504); }
} finally {
// Helper очищает inbox после чтения; резервная очистка также при обрыве
// exec/ошибке загрузки, не подменяя исходную ошибку транспорта.
if($inbox!==null&&$sftp!==null)try{$sftp->delete($inbox);}catch(\Throwable){}
}
}
if(!($result['ok']??false)) {if(!empty($result['conflict']))throw new Conflict();throw new Problem($result['error']??'Ошибка транспорта.');}return $result;
}
private static function inlineCommand(string $code,string $entry,string $argument): string {
if(!function_exists('gzdeflate'))throw new Problem('Для SSH-транспорта требуется расширение zlib.');
$compressed=gzdeflate($code.$entry,9);if($compressed===false)throw new Problem('Не удалось подготовить SSH helper.');
$runner='if(!function_exists("gzinflate")){echo json_encode(["ok"=>false,"error"=>"На удалённом PHP отсутствует zlib (gzinflate)."]);exit(1);}eval(gzinflate(base64_decode('.var_export(base64_encode($compressed),true).',true)));';
return 'php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg($argument);
}
private static function inboxCommand(string $code,string $inbox): string {
$entry="\n\$inbox=\$argv[1];try{echo json_encode(\\PrelandAdmin\\Remote::execute(json_decode(file_get_contents(\$inbox),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);}finally{unlink(\$inbox);}";
$command=self::inlineCommand($code,$entry,$inbox);
if(strlen($command)>self::COMMAND_LIMIT)throw new Problem('SSH helper превышает безопасный предел команды; требуется уменьшить bootstrap.');
return $command;
}
private function sftp(): SFTP {
$t=$this->target;$sftp=new SFTP($t['host'],$t['port'],20);$key=$sftp->getServerPublicHostKey();$parts=explode(' ',(string)$key);$fp='SHA256:'.rtrim(base64_encode(hash('sha256',base64_decode($parts[1]??'',true)?:'',true)),'=');$expected=$t['host_key'];
if(!is_string($key)||!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'=')))throw new Problem('Ключ SFTP хоста не совпадает.');