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
USER=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:
@@ -23,3 +23,16 @@ bash:
migrate:
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\Log\LoggerInterface;
// ---------------------------------------------------------------------
// PHP runtime
// ---------------------------------------------------------------------
error_reporting(E_ALL);
session_start();
// ---------------------------------------------------------------------
// ENV
// ---------------------------------------------------------------------

View File

@@ -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,
[

View File

@@ -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),
));
}
}

View File

@@ -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);

View File

@@ -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');
}
}