new csrf work flow

This commit is contained in:
2026-01-17 05:53:59 +07:00
parent 6bc7f8503a
commit fd8f022cae
7 changed files with 98 additions and 60 deletions

View File

@@ -2,3 +2,4 @@ STORAGE_PATH=storage
STORAGE_USER_LIMIT_GB=70 STORAGE_USER_LIMIT_GB=70
USER=admin USER=admin
PASSWORD=admin PASSWORD=admin
APP_KEY=9ccb8a8f9e47a93674db19e79d77f033fbde2f38ff426e88ca271fff13592e2b

View File

@@ -1,4 +1,4 @@
.PHONY: build up down install logs bash migrate .PHONY: build up down install logs bash migrate key
build: build:
@@ -23,3 +23,16 @@ bash:
migrate: migrate:
docker compose exec app php /var/www/db/migrate.php 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

View File

@@ -26,13 +26,6 @@ use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
// ---------------------------------------------------------------------
// PHP runtime
// ---------------------------------------------------------------------
error_reporting(E_ALL);
session_start();
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// ENV // ENV
// --------------------------------------------------------------------- // ---------------------------------------------------------------------

View File

@@ -13,22 +13,21 @@ use Psr\Log\LoggerInterface;
final readonly class AuthController final readonly class AuthController
{ {
public function __construct( public function __construct(
private LoginService $loginService, private LoginService $loginService,
private LoggerInterface $logger) private LoggerInterface $logger
)
{ {
} }
public function loginForm(): string public function loginForm(ServerRequestInterface $request): string
{ {
$error = $_SESSION['login_error'] ?? null; $error = $request->getQueryParams()['error'] ?? null;
unset($_SESSION['login_error']);
return View::display(new LoginViewModel( return View::display(new LoginViewModel(
title: 'Login', title: 'Login',
error: $error, error: $error,
csrf: CsrfMiddleware::generateToken() csrf: CsrfMiddleware::generateToken($request)
)); ));
} }
@@ -55,8 +54,6 @@ final readonly class AuthController
); );
if ($authToken !== null) { if ($authToken !== null) {
session_regenerate_id(true);
return new Response( return new Response(
302, 302,
[ [
@@ -69,9 +66,10 @@ final readonly class AuthController
); );
} }
$_SESSION['login_error'] = 'Invalid credentials'; return new Response(
302,
return new Response(302, ['Location' => '/login']); ['Location' => '/login?error=Invalid']
);
} }
public function logout(ServerRequestInterface $request): Response public function logout(ServerRequestInterface $request): Response
@@ -82,8 +80,6 @@ final readonly class AuthController
$this->loginService->logout($token); $this->loginService->logout($token);
} }
session_destroy();
return new Response( return new Response(
302, 302,
[ [

View File

@@ -62,7 +62,7 @@ final readonly class DashboardController
'folders' => [], 'folders' => [],
], ],
], ],
csrf: CsrfMiddleware::generateToken(), csrf: CsrfMiddleware::generateToken($request),
)); ));
} }
@@ -78,7 +78,7 @@ final readonly class DashboardController
'folders' => $folders, 'folders' => $folders,
], ],
], ],
csrf: CsrfMiddleware::generateToken(), csrf: CsrfMiddleware::generateToken($request),
)); ));
} }
} }

View File

@@ -125,7 +125,7 @@ final readonly class StorageController
public function showFolder(ServerRequestInterface $request, string $folder): string public function showFolder(ServerRequestInterface $request, string $folder): string
{ {
$user = $request->getAttribute('user'); $user = $request->getAttribute('user');
$csrfToken = CsrfMiddleware::generateToken(); $csrfToken = CsrfMiddleware::generateToken($request);
$folderPath = $this->storageService->getDriver()->getUserFolderPath($user->id, $folder); $folderPath = $this->storageService->getDriver()->getUserFolderPath($user->id, $folder);

View File

@@ -3,19 +3,23 @@ declare(strict_types=1);
namespace Din9xtrCloud\Middlewares; namespace Din9xtrCloud\Middlewares;
use JsonException;
use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Random\RandomException;
use RuntimeException; use RuntimeException;
final class CsrfMiddleware implements MiddlewareInterface final class CsrfMiddleware implements MiddlewareInterface
{ {
private const array UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']; 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(); $method = $request->getMethod();
$path = $request->getUri()->getPath(); $path = $request->getUri()->getPath();
@@ -25,11 +29,15 @@ final class CsrfMiddleware implements MiddlewareInterface
} }
if (in_array($method, self::UNSAFE_METHODS, true)) { if (in_array($method, self::UNSAFE_METHODS, true)) {
$token = $request->getParsedBody()['_csrf'] ?? null;
$token = $_POST['_csrf'] ?? ''; if (!$token || !self::validateToken($token, $request)) {
if (!isset($_SESSION['_csrf']) || $token !== $_SESSION['_csrf']) { return new Psr17Factory()
return new Psr17Factory()->createResponse(403) ->createResponse(403)
->withBody(new Psr17Factory()->createStream('CSRF validation failed')); ->withBody(
new Psr17Factory()
->createStream('CSRF validation failed')
);
} }
} }
@@ -38,36 +46,63 @@ final class CsrfMiddleware implements MiddlewareInterface
private function isExcludedPath(string $path): bool private function isExcludedPath(string $path): bool
{ {
if (str_starts_with($path, '/storage/tus')) { return
return true; str_starts_with($path, '/storage/tus') ||
} str_starts_with($path, '/api/') ||
str_starts_with($path, '/webhook/');
if (str_starts_with($path, '/api/')) {
return true;
}
if (str_starts_with($path, '/webhook/')) {
return true;
}
return false;
} }
public static function generateToken(): string public static function generateToken(ServerRequestInterface $request): string
{ {
if (empty($_SESSION['_csrf']) || $_SESSION['_csrf_expire'] < time()) { try {
try { $payload = json_encode([
$_SESSION['_csrf'] = bin2hex(random_bytes(32)); 'ts' => time(),
$_SESSION['_csrf_expire'] = time() + 3600; 'ua' => hash('sha256', $request->getHeaderLine('User-Agent')),
} catch (RandomException $e) { ], JSON_THROW_ON_ERROR);
throw new RuntimeException( } catch (JsonException $e) {
'Failed to generate CSRF token: ' . $e->getMessage(), error_log($e->getMessage());
0,
$e
);
}
} }
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');
} }
} }