From fd8f022cae1ae6b6103f34516caa1f959f07f19b Mon Sep 17 00:00:00 2001 From: din9xtr Date: Sat, 17 Jan 2026 05:53:59 +0700 Subject: [PATCH] new csrf work flow --- .env.example | 3 +- Makefile | 17 ++++- public/index.php | 7 -- src/Controllers/AuthController.php | 22 +++--- src/Controllers/DashboardController.php | 6 +- src/Controllers/StorageController.php | 4 +- src/Middlewares/CsrfMiddleware.php | 99 +++++++++++++++++-------- 7 files changed, 98 insertions(+), 60 deletions(-) diff --git a/.env.example b/.env.example index eaca763..f866bde 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ STORAGE_PATH=storage STORAGE_USER_LIMIT_GB=70 USER=admin -PASSWORD=admin \ No newline at end of file +PASSWORD=admin +APP_KEY=9ccb8a8f9e47a93674db19e79d77f033fbde2f38ff426e88ca271fff13592e2b \ No newline at end of file diff --git a/Makefile b/Makefile index 3e67a47..c81a09f 100755 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build up down install logs bash migrate +.PHONY: build up down install logs bash migrate key build: @@ -22,4 +22,17 @@ bash: docker compose exec app sh migrate: - docker compose exec app php /var/www/db/migrate.php \ No newline at end of file + docker compose exec app php /var/www/db/migrate.php + +key: + @echo "Generating new APP_KEY..." + @KEY=$$(docker compose exec -T app php -r "echo bin2hex(random_bytes(32));"); \ + if grep -q '^APP_KEY=' .env; then \ + sed -i "s/^APP_KEY=.*/APP_KEY=$$KEY/" .env; \ + else \ + echo "APP_KEY=$$KEY" >> .env; \ + fi; \ + echo "APP_KEY set to $$KEY"; \ + echo "Restarting app container to apply new key..."; \ + make down + make up \ No newline at end of file diff --git a/public/index.php b/public/index.php index cc1b16c..cb4bc29 100755 --- a/public/index.php +++ b/public/index.php @@ -26,13 +26,6 @@ use Nyholm\Psr7Server\ServerRequestCreator; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; -// --------------------------------------------------------------------- -// PHP runtime -// --------------------------------------------------------------------- -error_reporting(E_ALL); - -session_start(); - // --------------------------------------------------------------------- // ENV // --------------------------------------------------------------------- diff --git a/src/Controllers/AuthController.php b/src/Controllers/AuthController.php index df1f6db..e2976a2 100755 --- a/src/Controllers/AuthController.php +++ b/src/Controllers/AuthController.php @@ -13,22 +13,21 @@ use Psr\Log\LoggerInterface; final readonly class AuthController { - public function __construct( private LoginService $loginService, - private LoggerInterface $logger) + private LoggerInterface $logger + ) { } - public function loginForm(): string + public function loginForm(ServerRequestInterface $request): string { - $error = $_SESSION['login_error'] ?? null; - unset($_SESSION['login_error']); + $error = $request->getQueryParams()['error'] ?? null; return View::display(new LoginViewModel( title: 'Login', error: $error, - csrf: CsrfMiddleware::generateToken() + csrf: CsrfMiddleware::generateToken($request) )); } @@ -55,8 +54,6 @@ final readonly class AuthController ); if ($authToken !== null) { - session_regenerate_id(true); - return new Response( 302, [ @@ -69,9 +66,10 @@ final readonly class AuthController ); } - $_SESSION['login_error'] = 'Invalid credentials'; - - return new Response(302, ['Location' => '/login']); + return new Response( + 302, + ['Location' => '/login?error=Invalid'] + ); } public function logout(ServerRequestInterface $request): Response @@ -82,8 +80,6 @@ final readonly class AuthController $this->loginService->logout($token); } - session_destroy(); - return new Response( 302, [ diff --git a/src/Controllers/DashboardController.php b/src/Controllers/DashboardController.php index 9ce38d2..f2891c5 100755 --- a/src/Controllers/DashboardController.php +++ b/src/Controllers/DashboardController.php @@ -47,7 +47,7 @@ final readonly class DashboardController 'percent' => $percent, ]; } - + } catch (Throwable $exception) { $this->logger->error($exception->getMessage(), ['exception' => $exception]); return View::display(new DashboardViewModel( @@ -62,7 +62,7 @@ final readonly class DashboardController 'folders' => [], ], ], - csrf: CsrfMiddleware::generateToken(), + csrf: CsrfMiddleware::generateToken($request), )); } @@ -78,7 +78,7 @@ final readonly class DashboardController 'folders' => $folders, ], ], - csrf: CsrfMiddleware::generateToken(), + csrf: CsrfMiddleware::generateToken($request), )); } } \ No newline at end of file diff --git a/src/Controllers/StorageController.php b/src/Controllers/StorageController.php index c5d9efc..e3967d7 100644 --- a/src/Controllers/StorageController.php +++ b/src/Controllers/StorageController.php @@ -125,7 +125,7 @@ final readonly class StorageController public function showFolder(ServerRequestInterface $request, string $folder): string { $user = $request->getAttribute('user'); - $csrfToken = CsrfMiddleware::generateToken(); + $csrfToken = CsrfMiddleware::generateToken($request); $folderPath = $this->storageService->getDriver()->getUserFolderPath($user->id, $folder); @@ -327,7 +327,7 @@ final readonly class StorageController if (!is_array($data)) { return new Response(400); } - + $folder = $data['folder'] ?? ''; $raw = $data['file_names'] ?? '[]'; diff --git a/src/Middlewares/CsrfMiddleware.php b/src/Middlewares/CsrfMiddleware.php index 5feebb8..b1340d8 100755 --- a/src/Middlewares/CsrfMiddleware.php +++ b/src/Middlewares/CsrfMiddleware.php @@ -3,19 +3,23 @@ declare(strict_types=1); namespace Din9xtrCloud\Middlewares; +use JsonException; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Random\RandomException; use RuntimeException; final class CsrfMiddleware implements MiddlewareInterface { private const array UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']; + private const int TTL = 3600; - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface { $method = $request->getMethod(); $path = $request->getUri()->getPath(); @@ -25,11 +29,15 @@ final class CsrfMiddleware implements MiddlewareInterface } if (in_array($method, self::UNSAFE_METHODS, true)) { + $token = $request->getParsedBody()['_csrf'] ?? null; - $token = $_POST['_csrf'] ?? ''; - if (!isset($_SESSION['_csrf']) || $token !== $_SESSION['_csrf']) { - return new Psr17Factory()->createResponse(403) - ->withBody(new Psr17Factory()->createStream('CSRF validation failed')); + if (!$token || !self::validateToken($token, $request)) { + return new Psr17Factory() + ->createResponse(403) + ->withBody( + new Psr17Factory() + ->createStream('CSRF validation failed') + ); } } @@ -38,36 +46,63 @@ final class CsrfMiddleware implements MiddlewareInterface private function isExcludedPath(string $path): bool { - if (str_starts_with($path, '/storage/tus')) { - return true; - } - - if (str_starts_with($path, '/api/')) { - return true; - } - - if (str_starts_with($path, '/webhook/')) { - return true; - } - - return false; + return + str_starts_with($path, '/storage/tus') || + str_starts_with($path, '/api/') || + str_starts_with($path, '/webhook/'); } - public static function generateToken(): string + public static function generateToken(ServerRequestInterface $request): string { - if (empty($_SESSION['_csrf']) || $_SESSION['_csrf_expire'] < time()) { - try { - $_SESSION['_csrf'] = bin2hex(random_bytes(32)); - $_SESSION['_csrf_expire'] = time() + 3600; - } catch (RandomException $e) { - throw new RuntimeException( - 'Failed to generate CSRF token: ' . $e->getMessage(), - 0, - $e - ); - } + try { + $payload = json_encode([ + 'ts' => time(), + 'ua' => hash('sha256', $request->getHeaderLine('User-Agent')), + ], JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + error_log($e->getMessage()); } - return $_SESSION['_csrf']; + $payloadB64 = base64_encode($payload); + $signature = hash_hmac('sha256', $payloadB64, self::key(), true); + + return $payloadB64 . '.' . base64_encode($signature); + + } + + public static function validateToken( + string $token, + ServerRequestInterface $request + ): bool + { + [$payloadB64, $sigB64] = explode('.', $token, 2) + [null, null]; + + if (!$payloadB64 || !$sigB64) { + return false; + } + + $expected = hash_hmac('sha256', $payloadB64, self::key(), true); + if (!hash_equals($expected, base64_decode($sigB64))) { + return false; + } + + $payload = json_decode(base64_decode($payloadB64), true); + if (!isset($payload['ts'], $payload['ua'])) { + return false; + } + + if (time() - $payload['ts'] > self::TTL) { + return false; + } + + $uaHash = hash('sha256', $request->getHeaderLine('User-Agent')); + + return hash_equals($payload['ua'], $uaHash); + } + + private static function key(): string + { + return $_ENV['APP_KEY'] + ?? throw new RuntimeException('APP_KEY not defined'); } }