Browse Source

implement unified authentication system with PSR-15 middleware

michelphp 2 weeks ago
parent
commit
84aa664c3f

+ 1 - 0
composer.json

@@ -33,6 +33,7 @@
     "psr/event-dispatcher": "^1.0",
     "psr/http-factory": "^1.0",
     "psr/log": "^1.1|^2.0",
+    "michel/session": "^1.0",
     "michel/dotenv": "^1.0",
     "michel/router": "^1.0",
     "michel/options-resolver": "^1.0",

+ 4 - 3
functions/helpers.php

@@ -190,7 +190,8 @@ if (!function_exists('render_view')) {
     function render_view(string $view, array $context = []): string
     {
         if (!container()->has('render')) {
-            throw new LogicException('The "render_view" method requires a Renderer to be available. You can choose between installing "Michel/php-renderer" or "twig/twig" depending on your preference.');
+            throw new \LogicException('The "render_view" method requires the "michel/pure-plate" package. ' .
+            'Try running "composer require michel/pure-plate".');
         }
 
         $renderer = container()->get('render');
@@ -238,7 +239,7 @@ if (!function_exists('url')) {
     }
 }
 
-if (!function_exists('assets')) {
+if (!function_exists('asset')) {
 
     /**
      * Generates a URL for an asset.
@@ -246,7 +247,7 @@ if (!function_exists('assets')) {
      * @param string $path
      * @return string The dependency injection container.
      */
-    function assets(string $path): string
+    function asset(string $path): string
     {
         return '/'.ltrim($path, '/');
     }

+ 22 - 0
src/Auth/AuthHandlerInterface.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace Michel\Framework\Core\Auth;
+
+use Michel\Framework\Core\Auth\Exception\AuthenticationException;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+interface AuthHandlerInterface
+{
+    /**
+     * @throws AuthenticationException
+     */
+    public function authenticate(ServerRequestInterface $request):  ?AuthIdentity;
+
+    public function onFailure(
+        ServerRequestInterface $request,
+        ResponseFactoryInterface $responseFactory,
+        ?AuthenticationException $exception = null
+    ): ResponseInterface;
+}

+ 30 - 0
src/Auth/AuthIdentity.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace Michel\Framework\Core\Auth;
+
+final class AuthIdentity
+{
+    private UserInterface $user;
+    private bool $isNewLogin;
+
+    public function __construct(
+        UserInterface $user,
+        bool          $isNewLogin = false
+    )
+    {
+
+        $this->user = $user;
+        $this->isNewLogin = $isNewLogin;
+    }
+
+    public function getUser(): UserInterface
+    {
+        return $this->user;
+    }
+
+    public function isNewLogin(): bool
+    {
+        return $this->isNewLogin;
+    }
+
+}

+ 7 - 0
src/Auth/Exception/AuthenticationException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Michel\Framework\Core\Auth\Exception;
+
+class AuthenticationException extends \Exception
+{
+}

+ 7 - 0
src/Auth/Exception/InvalidCredentialsException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Michel\Framework\Core\Auth\Exception;
+
+final class InvalidCredentialsException extends AuthenticationException
+{
+}

+ 7 - 0
src/Auth/Exception/UserNotFoundException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Michel\Framework\Core\Auth\Exception;
+
+final class UserNotFoundException extends AuthenticationException
+{
+}

+ 90 - 0
src/Auth/Handler/FormAuthHandler.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace Michel\Framework\Core\Auth\Handler;
+
+use Michel\Framework\Core\Auth\AuthHandlerInterface;
+use Michel\Framework\Core\Auth\AuthIdentity;
+use Michel\Framework\Core\Auth\Exception\AuthenticationException;
+use Michel\Framework\Core\Auth\Exception\InvalidCredentialsException;
+use Michel\Framework\Core\Auth\Exception\UserNotFoundException;
+use Michel\Framework\Core\Auth\PasswordAuthenticatedUserInterface;
+use Michel\Framework\Core\Auth\UserInterface;
+use Michel\Framework\Core\Auth\UserProviderInterface;
+use Michel\Session\Storage\SessionStorageInterface;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+class FormAuthHandler implements AuthHandlerInterface
+{
+    /**
+     * @var callable
+     */
+    private $onFailure;
+
+    private UserProviderInterface $userProvider;
+    private SessionStorageInterface $sessionStorage;
+
+    public function __construct(
+        UserProviderInterface   $userProvider,
+        SessionStorageInterface $sessionStorage,
+        callable                $onFailure
+    )
+    {
+        $this->userProvider = $userProvider;
+        $this->sessionStorage = $sessionStorage;
+        $this->onFailure = $onFailure;
+    }
+
+    /**
+     * @throws AuthenticationException
+     * @throws UserNotFoundException
+     * @throws InvalidCredentialsException
+     */
+    public function authenticate(ServerRequestInterface $request): ?AuthIdentity
+    {
+        if ($this->sessionStorage->has('user_identifier')) {
+            $identifier = $this->sessionStorage->get('user_identifier');
+            $user = $this->userProvider->findByIdentifier($identifier);
+            if ($user instanceof UserInterface) {
+                return new AuthIdentity($user,  false);
+            }
+        }
+
+        if ($request->getMethod() !== 'POST') {
+            return null;
+        }
+
+        $data = $request->getParsedBody();
+        $login = $data['login'] ?? '';
+        $pass = $data['password'] ?? '';
+        if (empty($login) || empty($pass)) {
+            throw new InvalidCredentialsException("Credentials cannot be empty.");
+        }
+
+        /**
+         * @var PasswordAuthenticatedUserInterface|UserInterface|null $user
+         */
+        $user = $this->userProvider->findByIdentifier($login);
+        if (!$user instanceof UserInterface) {
+            throw new UserNotFoundException("No user found with the provided identifier.");
+        }
+
+        if (!$user instanceof PasswordAuthenticatedUserInterface) {
+            throw new AuthenticationException("The resolved user does not support password authentication.");
+        }
+
+        if (!$this->userProvider->checkPassword($user, $pass)) {
+            throw new InvalidCredentialsException("Invalid username or password.");
+        }
+
+        $this->sessionStorage->put('user_identifier', $user->getUserIdentifier());
+        return new AuthIdentity($user,  true);
+    }
+
+    public function onFailure(ServerRequestInterface $request, ResponseFactoryInterface $responseFactory, ?AuthenticationException $exception = null): ResponseInterface
+    {
+        return ($this->onFailure)($request, $responseFactory, $exception);
+    }
+
+}

+ 83 - 0
src/Auth/Handler/TokenAuthHandler.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace Michel\Framework\Core\Auth\Handler;
+
+use Michel\Framework\Core\Auth\AuthHandlerInterface;
+use Michel\Framework\Core\Auth\AuthIdentity;
+use Michel\Framework\Core\Auth\Exception\AuthenticationException;
+use Michel\Framework\Core\Auth\Exception\InvalidCredentialsException;
+use Michel\Framework\Core\Auth\Exception\UserNotFoundException;
+use Michel\Framework\Core\Auth\UserInterface;
+use Michel\Framework\Core\Auth\UserProviderInterface;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+class TokenAuthHandler implements AuthHandlerInterface
+{
+
+    private UserProviderInterface $userProvider;
+
+    private string $headerName;
+
+    /**
+     * @var callable|null
+     */
+    private $onFailure;
+    public function __construct(
+        UserProviderInterface $userProvider,
+        string                $headerName,
+        callable              $onFailure = null
+    )
+    {
+        $this->userProvider = $userProvider;
+        $this->headerName = $headerName;
+        $this->onFailure = $onFailure;
+    }
+
+    public function isAuthenticated(): bool
+    {
+        return false;
+    }
+
+
+    /**
+     * @throws AuthenticationException
+     * @throws UserNotFoundException
+     * @throws InvalidCredentialsException
+     */
+    public function authenticate(ServerRequestInterface $request): ?AuthIdentity
+    {
+        $token = $request->getHeaderLine($this->headerName);
+        if (empty($token)) {
+            throw new AuthenticationException("Token is required.");
+        }
+        $user = $this->userProvider->findByToken($token);
+        if (!$user instanceof UserInterface) {
+            throw new InvalidCredentialsException("The provided API key is invalid.");
+        }
+        return new AuthIdentity($user,  false);
+    }
+
+    public function onFailure(ServerRequestInterface $request, ResponseFactoryInterface $responseFactory, ?AuthenticationException $exception = null): ResponseInterface
+    {
+        if (!is_callable($this->onFailure)) {
+            $status = 401;
+            $message = $exception ? $exception->getMessage() : "Unauthorized access.";
+            $payload = [
+                'status' => $status,
+                'title'  => 'Authentication Failed',
+                'detail' => $message,
+            ];
+
+            $response = $responseFactory->createResponse($status);
+            $response->getBody()->write(json_encode($payload, JSON_UNESCAPED_SLASHES ));
+            return $response
+                ->withHeader('Content-Type', 'application/json')
+                ->withHeader('Cache-Control', 'no-store');
+
+        }
+        return ($this->onFailure)($request, $responseFactory, $exception);
+    }
+
+}

+ 70 - 0
src/Auth/Middlewares/AuthMiddleware.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Michel\Framework\Core\Auth\Middlewares;
+
+use Michel\Framework\Core\Auth\AuthHandlerInterface;
+use Michel\Framework\Core\Auth\AuthIdentity;
+use Michel\Framework\Core\Auth\Exception\AuthenticationException;
+use Michel\Framework\Core\Helper\IpHelper;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use Psr\Log\LoggerInterface;
+
+final class AuthMiddleware
+{
+    private AuthHandlerInterface $authHandler;
+    private ResponseFactoryInterface $responseFactory;
+
+    private ?LoggerInterface $logger;
+
+    public function __construct(
+        AuthHandlerInterface $authHandler,
+        ResponseFactoryInterface $responseFactory,
+        LoggerInterface $logger = null
+    )
+    {
+        $this->authHandler = $authHandler;
+        $this->responseFactory = $responseFactory;
+        $this->logger = $logger;
+    }
+
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        $handlerName = get_class($this->authHandler);
+
+        try {
+            $authIdentity = $this->authHandler->authenticate($request);
+            if ($authIdentity instanceof AuthIdentity) {
+                $user = $authIdentity->getUser();
+                $request = $request->withAttribute("user", $user);
+                if ($authIdentity->isNewLogin()) {
+                    $this->log('info', "[{handler}] Authentication successful : {id}.", [
+                        'handler' => $handlerName,
+                        'id'      => $user->getUserIdentifier()
+                    ]);
+                }
+                return $handler->handle($request);
+            }
+        }catch (AuthenticationException $exception) {
+            $this->log('warning', "[{handler}] Authentication failed: {ip} - {message}", [
+                'handler' => $handlerName,
+                'message' => $exception->getMessage(),
+                'ip'      => IpHelper::getIpFromRequest($request),
+            ]);
+            return $this->authHandler->onFailure($request, $this->responseFactory, $exception);
+        }
+
+        return $this->authHandler->onFailure($request, $this->responseFactory);
+    }
+
+
+    private function log(string $level, string $message, array $context = []): void
+    {
+        if ($this->logger === null) {
+            return;
+        }
+        $this->logger->log($level, $message, $context);
+    }
+}

+ 10 - 0
src/Auth/PasswordAuthenticatedUserInterface.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Michel\Framework\Core\Auth;
+
+interface PasswordAuthenticatedUserInterface
+{
+    public function getPassword(): string;
+
+    public function setPassword(?string $password);
+}

+ 8 - 0
src/Auth/UserInterface.php

@@ -0,0 +1,8 @@
+<?php
+
+namespace Michel\Framework\Core\Auth;
+
+interface UserInterface
+{
+    public function getUserIdentifier(): string;
+}

+ 11 - 0
src/Auth/UserProviderInterface.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace Michel\Framework\Core\Auth;
+
+interface UserProviderInterface
+{
+    public function findByIdentifier(string $identifier): ?UserInterface;
+    public function findByToken(string $token): ?UserInterface;
+    public function checkPassword(PasswordAuthenticatedUserInterface $user, string $plainPassword): bool;
+    public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newPlainPassword): void;
+}

+ 1 - 1
src/Helper/IpHelper.php

@@ -21,7 +21,7 @@ final class IpHelper
             return $serverParams['REMOTE_ADDR'];
         }
 
-        return '127.0.0.1';
+        return '';
     }
 
 }

+ 3 - 7
src/Http/RequestContext.php

@@ -2,6 +2,7 @@
 
 namespace Michel\Framework\Core\Http;
 
+use Michel\Framework\Core\Auth\UserInterface;
 use Michel\Route;
 use Michel\RouterMiddleware;
 use Psr\Http\Message\ServerRequestInterface;
@@ -9,7 +10,6 @@ use Psr\Http\Message\ServerRequestInterface;
 class RequestContext
 {
     private ?ServerRequestInterface $request = null;
-    private ?object $user = null;
 
     public function setRequest(ServerRequestInterface $request): void
     {
@@ -32,12 +32,8 @@ class RequestContext
         return $route->getName();
     }
 
-    public function setUser(object $user): void
+    public function getUser(): ?UserInterface
     {
-        $this->user = $user;
-    }
-    public function getUser(): ?object
-    {
-        return $this->user;
+        return $this->request->getAttribute('user');
     }
 }

+ 1 - 1
src/Middlewares/DebugMiddleware.php

@@ -28,7 +28,7 @@ final class DebugMiddleware implements MiddlewareInterface
             Option::bool('debug', false),
             Option::bool('profiler', false),
             Option::string('env', 'prod'),
-            Option::string('log_dir', 'prod')->validator(function ($value) {
+            Option::string('log_dir')->validator(function ($value) {
                 return file_exists($value);
             }),
         ]);

+ 4 - 3
src/Middlewares/MaintenanceMiddleware.php

@@ -2,6 +2,7 @@
 
 namespace Michel\Framework\Core\Middlewares;
 
+use Closure;
 use Michel\Framework\Core\Helper\IpHelper;
 use Psr\Http\Message\ResponseFactoryInterface;
 use Psr\Http\Message\ResponseInterface;
@@ -15,9 +16,9 @@ final class MaintenanceMiddleware implements MiddlewareInterface
     private ResponseFactoryInterface $responseFactory;
 
     private array $allowedIps;
-    private ?\Closure $renderer;
+    private ?Closure $renderer;
 
-    public function __construct(bool $maintenanceMode, ResponseFactoryInterface $responseFactory,array $allowedIps = [], \Closure $renderer = null)
+    public function __construct(bool $maintenanceMode, ResponseFactoryInterface $responseFactory, array $allowedIps = [], Closure $renderer = null)
     {
         $this->maintenanceMode = $maintenanceMode;
         $this->responseFactory = $responseFactory;
@@ -33,7 +34,7 @@ final class MaintenanceMiddleware implements MiddlewareInterface
             if ($this->renderer !== null) {
                 $renderer = $this->renderer;
                 $response->getBody()->write($renderer($request));
-            }else{
+            } else {
                 $response->getBody()->write('
     <html>
         <head>