diff --git a/docker/web/development/php.ini b/docker/web/development/php.ini index c70a227982..248dbf162b 100644 --- a/docker/web/development/php.ini +++ b/docker/web/development/php.ini @@ -39,7 +39,7 @@ assert.exception = 1 zend.assertions = 1 [opcache] -opcache.enable = 1 +opcache.enable = 0 opcache.memory_consumption = 256 opcache.interned_strings_buffer = 16 opcache.max_accelerated_files = 10000 @@ -67,6 +67,9 @@ session.gc_divisor = 100 session.gc_maxlifetime = 43200 [XDebug] +xdebug.remote_autostart=0 +xdebug.remote_enable=0 +xdebug.profiler_enable=0 xdebug.max_nesting_level = 256 xdebug.mode = develop,coverage,debug xdebug.client_host = host.docker.internal diff --git a/module/Activity/config/module.config.php b/module/Activity/config/module.config.php index 526dbad3ae..e088a2affb 100644 --- a/module/Activity/config/module.config.php +++ b/module/Activity/config/module.config.php @@ -23,6 +23,7 @@ use Doctrine\ORM\Mapping\Driver\AttributeDriver; use Laminas\Router\Http\Literal; use Laminas\Router\Http\Segment; +use User\Listener\Authentication; return [ 'router' => [ @@ -449,6 +450,7 @@ 'defaults' => [ 'controller' => ApiController::class, 'action' => 'list', + 'auth_type' => Authentication::AUTH_API, ], ], 'may_terminate' => false, diff --git a/module/Application/config/module.config.php b/module/Application/config/module.config.php index 1ee5a56cac..1905ef5714 100644 --- a/module/Application/config/module.config.php +++ b/module/Application/config/module.config.php @@ -97,12 +97,14 @@ 'template_map' => [ 'layout/layout' => __DIR__ . '/../view/layout/layout.phtml', 'application/index/teapot' => __DIR__ . '/../view/error/418.phtml', - 'error/404' => __DIR__ . '/../view/error/404.phtml', + 'error/401' => __DIR__ . '/../view/error/401.phtml', 'error/403' => __DIR__ . '/../view/error/403.phtml', + 'error/404' => __DIR__ . '/../view/error/404.phtml', 'error/418' => __DIR__ . '/../view/error/418.phtml', 'error/500' => __DIR__ . '/../view/error/500.phtml', - 'error/debug/404' => __DIR__ . '/../view/error/debug/404.phtml', + 'error/debug/401' => __DIR__ . '/../view/error/debug/401.phtml', 'error/debug/403' => __DIR__ . '/../view/error/debug/403.phtml', + 'error/debug/404' => __DIR__ . '/../view/error/debug/404.phtml', 'error/debug/500' => __DIR__ . '/../view/error/debug/500.phtml', 'paginator/default' => __DIR__ . '/../view/partial/paginator.phtml', ], diff --git a/module/Application/src/Model/Enums/ApiResponseStatuses.php b/module/Application/src/Model/Enums/ApiResponseStatuses.php new file mode 100644 index 0000000000..d0bf5944ec --- /dev/null +++ b/module/Application/src/Model/Enums/ApiResponseStatuses.php @@ -0,0 +1,23 @@ +attach($eventManager); // Attach listener for locale determination through the `LanguageAwareTreeRouteStack`. - $eventManager->attach(MvcEvent::EVENT_ROUTE, [$this, 'onRoute']); + $eventManager->attach(MvcEvent::EVENT_ROUTE, [$this, 'onRoute'], 100); $eventManager->attach(MvcEvent::EVENT_DISPATCH_ERROR, [$this, 'logError']); $eventManager->attach(MvCEvent::EVENT_RENDER_ERROR, [$this, 'logError']); diff --git a/module/Application/view/error/401.phtml b/module/Application/view/error/401.phtml new file mode 100644 index 0000000000..d281cffeab --- /dev/null +++ b/module/Application/view/error/401.phtml @@ -0,0 +1,22 @@ + +
+
+

translate('Unauthenticated') ?>

+

translate('Log in to access this page.') ?>

+ + + translate('Login') ?> + +
+
diff --git a/module/Application/view/error/403.phtml b/module/Application/view/error/403.phtml index 7e322eb05c..e04e75cb84 100644 --- a/module/Application/view/error/403.phtml +++ b/module/Application/view/error/403.phtml @@ -9,26 +9,10 @@ use Laminas\View\Renderer\PhpRenderer; ?>
- exception->getMessage())): ?> + exception->getMessage())): ?>

exception->getMessage() ?>

- +

translate('You do not have the required privileges to view this page') ?>

- - identity() === null): ?> -

translate('You might be able to view this page by logging in') ?>

- - - translate('Login') ?> - - - +
diff --git a/module/Application/view/error/debug/401.phtml b/module/Application/view/error/debug/401.phtml new file mode 100644 index 0000000000..7eed734af6 --- /dev/null +++ b/module/Application/view/error/debug/401.phtml @@ -0,0 +1,15 @@ + +
+
+

translate('403 Unauthenticated') ?>

+

exception->getMessage() ?>

+
+
diff --git a/module/Application/view/layout/layout.phtml b/module/Application/view/layout/layout.phtml index 052a63c008..4e0ebc02b8 100644 --- a/module/Application/view/layout/layout.phtml +++ b/module/Application/view/layout/layout.phtml @@ -189,11 +189,9 @@ $viewModel = current($this->viewModel()->getCurrent()->getChildren()); if (str_contains($viewModel->getTemplate(), 'admin')): ?> partial('partial/admin.phtml', ['content' => $this->content]) ?> -getTemplate(), 'company-account')): ?> +getTemplate(), 'company-account')): ?> partial('partial/company.phtml', ['content' => $this->content]) ?> - +
content ?>
diff --git a/module/Company/config/module.config.php b/module/Company/config/module.config.php index 6dc9275e3a..4ea4f75791 100644 --- a/module/Company/config/module.config.php +++ b/module/Company/config/module.config.php @@ -16,6 +16,7 @@ use Doctrine\ORM\Mapping\Driver\AttributeDriver; use Laminas\Router\Http\Literal; use Laminas\Router\Http\Segment; +use User\Listener\Authentication; return [ 'router' => [ @@ -124,6 +125,7 @@ 'route' => '/company', 'defaults' => [ 'controller' => CompanyAccountController::class, + 'auth_type' => Authentication::AUTH_COMPANY_USER, ], ], 'may_terminate' => false, diff --git a/module/Photo/config/module.config.php b/module/Photo/config/module.config.php index 5c1bb0a38a..1929618d19 100644 --- a/module/Photo/config/module.config.php +++ b/module/Photo/config/module.config.php @@ -20,6 +20,7 @@ use Photo\Controller\PhotoAdminController; use Photo\Controller\PhotoController; use Photo\Controller\TagController; +use User\Listener\Authentication; return [ 'router' => [ @@ -31,6 +32,7 @@ 'defaults' => [ 'controller' => PhotoController::class, 'action' => 'index', + 'auth_type' => Authentication::AUTH_USER, ], ], 'may_terminate' => true, @@ -172,6 +174,7 @@ 'defaults' => [ 'controller' => AlbumAdminController::class, 'action' => 'index', + 'auth_type' => Authentication::AUTH_USER, ], ], 'may_terminate' => true, @@ -329,6 +332,7 @@ 'defaults' => [ 'controller' => ApiController::class, 'action' => 'index', + 'auth_type' => Authentication::AUTH_API, ], ], 'may_terminate' => true, diff --git a/module/User/config/module.config.php b/module/User/config/module.config.php index 9f2124d64f..668edd0b12 100644 --- a/module/User/config/module.config.php +++ b/module/User/config/module.config.php @@ -16,6 +16,7 @@ use User\Controller\Factory\UserControllerFactory; use User\Controller\UserAdminController; use User\Controller\UserController; +use User\Listener\Authentication; return [ 'router' => [ @@ -120,6 +121,9 @@ 'type' => Literal::class, 'options' => [ 'route' => '/admin/user', + 'defaults' => [ + 'auth_type' => Authentication::AUTH_USER, + ], ], 'may_terminate' => false, 'child_routes' => [ @@ -177,6 +181,7 @@ 'defaults' => [ 'controller' => ApiAuthenticationController::class, 'action' => 'token', + 'auth_type' => Authentication::AUTH_USER, ], ], 'priority' => 100, diff --git a/module/User/src/Listener/Authentication.php b/module/User/src/Listener/Authentication.php new file mode 100644 index 0000000000..e1451bec1d --- /dev/null +++ b/module/User/src/Listener/Authentication.php @@ -0,0 +1,180 @@ + $userAuthService + * @psalm-param CompanyUserAuthenticationService $companyUserAuthService + */ + public function __construct( + private readonly UserAuthenticationService $userAuthService, + private readonly CompanyUserAuthenticationService $companyUserAuthService, + private readonly ApiAuthenticationService $apiUserAuthService, + ) { + } + + public function __invoke(MvcEvent $e): ?ResponseInterface + { + if (MvcEvent::EVENT_ROUTE !== $e->getName()) { + throw new InvalidArgumentException( + 'Expected MvcEvent of type ' . MvcEvent::EVENT_ROUTE . ', got ' . $e->getName(), + ); + } + + $match = $e->getRouteMatch(); + if (null === $match) { + throw new LogicException('Did not match any route after being routed'); + } + + return match ($match->getParam('auth_type', self::AUTH_NONE)) { + self::AUTH_USER => $this->userAuth($e, $this->userAuthService), + self::AUTH_COMPANY_USER => $this->userAuth($e, $this->companyUserAuthService), + self::AUTH_API => $this->apiAuth($e), + self::AUTH_NONE => null, + default => throw new InvalidArgumentException( + 'Authentication type was set to unknown type ' . $match->getParam('auth_type'), + ), + }; + } + + /** + * Handle authentication for (company) users. + * + * @psalm-param UserAuthenticationService|CompanyUserAuthenticationService $authService + */ + private function userAuth( + MvcEvent $e, + AuthenticationServiceInterface $authService, + ): ?ResponseInterface { + if ($authService->hasIdentity()) { + // User is logged in, just continue + return null; + } + + /** @var HttpResponse $response */ + $response = $e->getResponse(); +// $e->stopPropagation(); + + // If a user of another type is trying to access a route. + if ($this->isOtherUserAuthenticated($authService)) { + $viewModel = new ViewModel(); + $viewModel->setTemplate('error/403'); + + $e->getViewModel()->addChild($viewModel) + ->setVariable('exception', 'test'); + $e->setError(Application::ERROR_EXCEPTION) + ->setParam('exception', new NotAllowedException('Forbidden')); + + return null; +// $response->setStatusCode(HttpResponse::STATUS_CODE_403); +// return $response; + } + +// $viewModel = new ViewModel(); +// $viewModel->setTemplate('production' === APP_ENV ? 'error/403' : 'error/debug/403'); +// $e->getViewModel()->addChild($viewModel); + + // TODO: check if it is possible to pass whether user has to login as user or as company user. + $viewModel = new ViewModel(); + $viewModel->setTemplate('error/401'); + + $e->getViewModel()->addChild($viewModel) + ->setVariable('exception', 'test'); + $e->setError(Application::ERROR_EXCEPTION) + ->setParam('exception', new NotAuthenticatedException('Unauthenticated')); + + return null; +// $response->setStatusCode(HttpResponse::STATUS_CODE_401); +// +// return $response; + } + + /** + * Handle authentication for api tokens + */ + private function apiAuth(MvcEvent $e): ?ResponseInterface + { + $request = $e->getRequest(); + + // TODO: remove X-Auth-Token authentication after December 31, 2024. + if ($request->getHeaders()->has('X-Auth-Token')) { + // check if this is a valid token + $token = $request->getHeader('X-Auth-Token')->getFieldValue(); + $result = $this->apiUserAuthService->authenticate($token); + + if ($result->isValid()) { + return null; + } + } + + // TODO: make authentication using Bearer the default after December 31, 2024. + if ($request->getHeaders()->has('Authorization')) { + // This is an API call, we do this on every request + $token = $request->getHeader('Authorization')->getFieldValue(); + $result = $this->apiUserAuthService->authenticate(substr($token, strlen('Bearer '))); + + if ($result->isValid()) { + return null; + } + } + + // If authentication failed and if this is an HTTP request, we add authentication headers. + $response = $e->getResponse(); + if ($response instanceof HttpResponse) { + $response->getHeaders()->addHeaderLine('WWW-Authenticate', 'Bearer realm="/api"'); + $response->setStatusCode(HttpResponse::STATUS_CODE_401); + } + + $e->stopPropagation(); + + return $response; + } + + private function isOtherUserAuthenticated(AuthenticationServiceInterface $currentAuthService): bool + { + $otherAuthServices = [$this->userAuthService, $this->companyUserAuthService, $this->apiUserAuthService]; + + foreach ($otherAuthServices as $authService) { + if ( + $authService !== $currentAuthService + && $authService->hasIdentity() + ) { + return true; + } + } + + return false; + } +} diff --git a/module/User/src/Listener/Authorization.php b/module/User/src/Listener/Authorization.php new file mode 100644 index 0000000000..253cd4c496 --- /dev/null +++ b/module/User/src/Listener/Authorization.php @@ -0,0 +1,60 @@ +getError() + || null === $e->getParam('exception') + || ( + !($e->getParam('exception') instanceof NotAllowedException) + && !($e->getParam('exception') instanceof NotAuthenticatedException) + ) + ) { + return; + } + + $request = $e->getRequest(); + if ($e->getParam('exception') instanceof NotAllowedException) { + if ( + $request instanceof HttpRequest + && str_starts_with($request->getUri()->getPath(), '/api') + ) { + // Handle API request + $e->setViewModel(new JsonModel([ + 'status' => ApiResponseStatuses::Forbidden, + 'error' => [ + 'type' => NotAllowedException::class, + 'exception' => $e->getParam('exception')->getMessage(), + ], + ])); + } else { + // Handle non-API request + $e->getResult()->setTemplate('production' === APP_ENV ? 'error/403' : 'error/debug/403'); + $e->getResponse()->setStatusCode(HttpResponse::STATUS_CODE_403); + } + +// $e->stopPropagation(); + + return; + } + + if ($e->getParam('exception') instanceof NotAuthenticatedException) { + $e->getResult()->setTemplate('production' === APP_ENV ? 'error/401' : 'error/debug/401'); + $e->getResponse()->setStatusCode(HttpResponse::STATUS_CODE_401); + } + } +} diff --git a/module/User/src/Listener/DispatchErrorFormatter.php b/module/User/src/Listener/DispatchErrorFormatter.php new file mode 100644 index 0000000000..00016a0250 --- /dev/null +++ b/module/User/src/Listener/DispatchErrorFormatter.php @@ -0,0 +1,143 @@ +getError()) { + $this->handleNoMatchedRoute($e); + + return; + } + + /** + * If this is not an error-router-no-match error, we must have a matching route + */ + $match = $e->getRouteMatch(); + + // We should always have a match here; if we do not, throw an exception + // possibly including previous exceptions + if (null === $match) { + throw new LogicException( + message: 'Assumed route would be present; no route present', + previous: $e->getParam('exception', null), + ); + } + + // If we do have a match, this implies we have properly authenticated before + $this->handleMatchedRoute($e, $match); + } + + private function matchAncestorRoute( + Request $request, + Router $router, + ): ?RouteMatch { + $request = clone $request; + $uri = clone $request->getUri(); + $path = $uri->getPath(); + $match = null; + + while (null === $match && str_contains($path, '/')) { + $path = substr($path, 0, strrpos($path, '/')); + + if ('' === $path) { + $uri->setPath('/'); + } else { + $uri->setPath($path); + } + + $request->setUri($uri); + $match = $router->match($request); + } + + return $match; + } + + private function isApiMatch(RouteMatch $match): bool + { + return Authentication::AUTH_API === $match->getParam('auth_type', Authentication::AUTH_NONE); + } + + private function handleNoMatchedRoute(MvcEvent $e): void + { + $router = $e->getRouter(); + $request = $e->getRequest(); + + // If this is not an HTTP request, we cannot assume anything about routes + if (!($request instanceof Request)) { + return; + } + + $match = $this->matchAncestorRoute($request, $router); + + // Regular routes are dealt with by default handling + if (!$this->isApiMatch($match)) { + return; + } + + // If this is probably an API route, response should be JSON + $view = new JsonModel([ + 'status' => ApiResponseStatuses::NotFound, + 'error' => [ + 'type' => $e->getError(), + 'exception' => $e->getParam('exception')?->getMessage(), + ], + ]); + + $e->setViewModel($view); + $response = $e->getResponse(); + if ($response instanceof HttpResponse) { + $response->setStatusCode(HttpResponse::STATUS_CODE_404); + } + + $e->stopPropagation(); + } + + private function handleMatchedRoute( + MvcEvent $e, + RouteMatch $match, + ): void { + // Regular routes are dealt with by default handling + if (!$this->isApiMatch($match)) { + return; + } + + // If this is probably an API route, response should be JSON + $view = new JsonModel([ + 'status' => ApiResponseStatuses::Error, + 'error' => [ + 'type' => $e->getError(), + 'exception' => $e->getParam('exception')?->getMessage(), + ], + ]); + + $e->setViewModel($view); + $response = $e->getResponse(); + if ($response instanceof HttpResponse) { + $response->setStatusCode(HttpResponse::STATUS_CODE_500); + } + + $e->stopPropagation(); + } +} diff --git a/module/User/src/Module.php b/module/User/src/Module.php index a972ab99bd..0c97b88318 100644 --- a/module/User/src/Module.php +++ b/module/User/src/Module.php @@ -9,7 +9,6 @@ use Laminas\Authentication\AuthenticationService as LaminasAuthenticationService; use Laminas\Crypt\Password\Bcrypt; use Laminas\Http\PhpEnvironment\RemoteAddress; -use Laminas\Http\Request as HttpRequest; use Laminas\Mvc\I18n\Translator as MvcTranslator; use Laminas\Mvc\MvcEvent; use Psr\Container\ContainerInterface; @@ -32,6 +31,9 @@ use User\Form\Register as RegisterForm; use User\Form\UserLogin as UserLoginForm; use User\Form\UserReset as ResetForm; +use User\Listener\Authentication; +use User\Listener\Authorization; +use User\Listener\DispatchErrorFormatter; use User\Mapper\ApiApp as ApiAppMapper; use User\Mapper\ApiAppAuthentication as ApiAppAuthenticationMapper; use User\Mapper\ApiUser as ApiUserMapper; @@ -41,7 +43,6 @@ use User\Mapper\NewCompanyUser as NewCompanyUserMapper; use User\Mapper\NewUser as NewUserMapper; use User\Mapper\User as UserMapper; -use User\Permissions\NotAllowedException; use User\Service\ApiApp as ApiAppService; use User\Service\ApiUser as ApiUserService; use User\Service\Email as EmailService; @@ -57,40 +58,32 @@ class Module */ public function onBootstrap(MvcEvent $e): void { + $sm = $e->getApplication()->getServiceManager(); $em = $e->getApplication()->getEventManager(); - // check if the user has a valid API token - $request = $e->getRequest(); - - if (($request instanceof HttpRequest) && $request->getHeaders()->has('X-Auth-Token')) { - // check if this is a valid token - $token = $request->getHeader('X-Auth-Token') - ->getFieldValue(); - - $container = $e->getApplication()->getServiceManager(); - /** @var ApiAuthenticationService $service */ - $service = $container->get('user_auth_apiUser_service'); - $service->authenticate($token); - } - - // this event listener will turn the request into '403 Forbidden' when - // there is a NotAllowedException + // Establish an identity of the user using the authentication listener. + /** @var AuthenticationService $userAuthService */ + $userAuthService = $sm->get('user_auth_user_service'); + /** @var AuthenticationService $companyUserAuthService */ + $companyUserAuthService = $sm->get('user_auth_companyUser_service'); + $apiUserAuthService = $sm->get('user_auth_apiUser_service'); $em->attach( - MvcEvent::EVENT_DISPATCH_ERROR, - static function ($e): void { - if ( - 'error-exception' !== $e->getError() - || null === $e->getParam('exception', null) - || !($e->getParam('exception') instanceof NotAllowedException) - ) { - return; - } - - $e->getResult()->setTemplate(('production' === APP_ENV ? 'error/403' : 'error/debug/403')); - $e->getResponse()->setStatusCode(403); - }, + MvcEvent::EVENT_ROUTE, + new Authentication( + $userAuthService, + $companyUserAuthService, + $apiUserAuthService, + ), -100, ); + + // Catch authorization exceptions + // $em->attach(MvcEvent::EVENT_DISPATCH_ERROR, new Authorization(), 10); + + // Format errors in case of dispatch errors after authentication + // $em->attach(MvcEvent::EVENT_DISPATCH_ERROR, new DispatchErrorFormatter(), 5); + +// $em = $e->getApplication()->getEventManager(); } /** diff --git a/module/User/src/Permissions/NotAuthenticatedException.php b/module/User/src/Permissions/NotAuthenticatedException.php new file mode 100644 index 0000000000..cd1d87c6c9 --- /dev/null +++ b/module/User/src/Permissions/NotAuthenticatedException.php @@ -0,0 +1,11 @@ +