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 @@
+
+
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())): ?>
= $this->exception->getMessage() ?>
-
+
= $this->translate('You do not have the required privileges to view this page') ?>
-
- identity() === null): ?>
-
= $this->translate('You might be able to view this page by logging in') ?>
-
-
- = $this->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 @@
+
+
+
+
= $this->translate('403 Unauthenticated') ?>
+ = $this->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')): ?>
= $this->partial('partial/admin.phtml', ['content' => $this->content]) ?>
-getTemplate(), 'company-account')): ?>
+getTemplate(), 'company-account')): ?>
= $this->partial('partial/company.phtml', ['content' => $this->content]) ?>
-
+
= $this->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 @@
+