Bladeren bron

core implementation of the authentication system

michelphp 1 dag geleden
commit
68a142d175

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+/.idea/
+/vendor
+/composer.lock

+ 373 - 0
LICENSE

@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.

+ 68 - 0
README.md

@@ -0,0 +1,68 @@
+# Michel Auth
+
+A flexible and lightweight PSR-15 compliant authentication library for PHP applications. This library provides a middleware-based approach to handle user authentication, supporting both traditional form-based logins and token-based API authentication.
+
+## Features
+
+- **PSR-15 Middleware:** Seamlessly integrates into any modern PHP framework or application that supports PSR-15 middleware (`AuthMiddleware`).
+- **Multiple Handlers:**
+  - `FormAuthHandler`: For handling classical HTML form logins. Relies on `michel/session` to persist user sessions.
+  - `TokenAuthHandler`: For handling API authentications via HTTP headers (e.g., Bearer tokens, API keys).
+- **Customizable:** Implements `UserProviderInterface` to easily plug in your own user storage (database, memory, external APIs).
+- **Security:** Built-in interfaces (`PasswordAuthenticatedUserInterface`) for secure password checking and automatic password upgrades.
+- **Error Handling:** Easily catch and handle authentication failures gracefully with custom callbacks (`onFailure`).
+
+## Installation
+
+You can install the library via Composer:
+
+```bash
+composer require michel/michel-auth
+```
+
+## Basic Usage
+
+### 1. Implement User and Provider Interfaces
+
+First, create a user class that implements `Michel\Auth\UserInterface` (and optionally `Michel\Auth\PasswordAuthenticatedUserInterface` for form login).
+
+Then, create a provider implementing `Michel\Auth\UserProviderInterface` to fetch these users.
+
+### 2. Form Authentication
+
+Set up form authentication for your web application.
+
+```php
+use Michel\Auth\Handler\FormAuthHandler;
+use Michel\Auth\Middlewares\AuthMiddleware;
+
+// $userProvider = new YourUserProvider();
+// $sessionStorage = new YourSessionStorage();
+
+$formHandler = new FormAuthHandler($userProvider, $sessionStorage, [
+    'login_path' => '/login',
+    'login_key' => 'email',
+    'password_key' => 'password',
+]);
+
+$authMiddleware = new AuthMiddleware($formHandler, $responseFactory, $logger);
+// Add $authMiddleware to your PSR-15 compatible application router/dispatcher
+```
+
+### 3. Token Authentication (API)
+
+Ideal for stateless APIs using header tokens.
+
+```php
+use Michel\Auth\Handler\TokenAuthHandler;
+use Michel\Auth\Middlewares\AuthMiddleware;
+
+$tokenHandler = new TokenAuthHandler($userProvider, 'Authorization');
+
+$authMiddleware = new AuthMiddleware($tokenHandler, $responseFactory, $logger);
+// Add $authMiddleware to your API routes
+```
+
+## License
+
+This project is licensed under the MPL-2.0 License. See the [LICENSE](LICENSE) file for details.

+ 32 - 0
composer.json

@@ -0,0 +1,32 @@
+{
+  "name": "michel/michel-auth",
+  "description": "A PSR-15 compliant authentication library providing form and token-based authentication handlers.",
+  "type": "library",
+  "require": {
+    "php": ">=7.4",
+    "michel/michel-package-starter": "^1.0",
+    "michel/options-resolver": "^1.0",
+    "michel/session": "^1.0",
+    "michel/console": "^1.0",
+    "psr/http-message": "~1.0|^2.0",
+    "psr/http-server-middleware": "^1.0",
+    "psr/http-factory": "^1.0",
+    "psr/log": "^1.1|^2.0",
+    "ext-json": "*"
+  },
+  "license": "MPL-2.0",
+  "authors": [
+    {
+      "name": "F. Michel"
+    }
+  ],
+  "autoload": {
+    "psr-4": {
+      "Michel\\Auth\\": "src",
+      "Test\\Michel\\Auth\\": "tests"
+    }
+  },
+  "require-dev": {
+    "michel/unitester": "^1.0.0"
+  }
+}

+ 22 - 0
src/AuthHandlerInterface.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace Michel\Auth;
+
+use Michel\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;
+}

+ 29 - 0
src/AuthIdentity.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace Michel\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;
+    }
+}

+ 67 - 0
src/Command/AuthPasswordHashCommand.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace Michel\Auth\Command;
+
+use Michel\Auth\UserProviderInterface;
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Output\ConsoleOutput;
+use Michel\Console\OutputInterface;
+
+class AuthPasswordHashCommand implements CommandInterface
+{
+    private UserProviderInterface $userProvider;
+    public function __construct(UserProviderInterface $userProvider)
+    {
+        $this->userProvider = $userProvider;
+    }
+
+    public function getName(): string
+    {
+        return 'auth:password:hash';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Hash a raw password string into a secure hash compatible with UserProvider.';
+    }
+
+    public function getOptions(): array
+    {
+        return[];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+            CommandArgument::required('password', 'The plain-text password to hash')
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $io = new ConsoleOutput($output);
+        $plainPassword = $input->getArgumentValue('password');
+        if (empty($plainPassword)) {
+            $io->error('The password cannot be empty.');
+            return;
+        }
+
+
+        $io->title('Password Hashing Tool');
+
+        $hash = $this->userProvider->hashPassword($plainPassword);
+        $info = password_get_info($hash);
+        $io->listKeyValues([
+            'Algorithm' => $info['algoName'] ?? 'unknown',
+            'Options'   => json_encode($info['options']),
+        ]);
+
+        $io->writeln('');
+        $io->writeln("Computed Hash: " . $hash);
+        $io->writeln('');
+
+        $io->success('Password hashed successfully.');
+    }
+}

+ 7 - 0
src/Exception/AuthenticationException.php

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

+ 7 - 0
src/Exception/InvalidCredentialsException.php

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

+ 7 - 0
src/Exception/UserNotFoundException.php

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

+ 142 - 0
src/Handler/FormAuthHandler.php

@@ -0,0 +1,142 @@
+<?php
+
+namespace Michel\Auth\Handler;
+
+use Michel\Auth\AuthHandlerInterface;
+use Michel\Auth\AuthIdentity;
+use Michel\Auth\Exception\AuthenticationException;
+use Michel\Auth\Exception\InvalidCredentialsException;
+use Michel\Auth\Exception\UserNotFoundException;
+use Michel\Auth\PasswordAuthenticatedUserInterface;
+use Michel\Auth\UserInterface;
+use Michel\Auth\UserProviderInterface;
+use Michel\Resolver\Option;
+use Michel\Resolver\OptionsResolver;
+use Michel\Session\Storage\SessionStorageInterface;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+class FormAuthHandler implements AuthHandlerInterface
+{
+    public const AUTHENTICATION_ERROR = '_form.last_error';
+    public const LAST_USERNAME = '_form.last_username';
+
+    private UserProviderInterface $userProvider;
+    private SessionStorageInterface $sessionStorage;
+    private string $loginPath;
+    private string $loginKey;
+    private string $passwordKey;
+
+    /**
+     * @var callable
+     */
+    private $onFailure;
+
+    public function __construct(
+        UserProviderInterface   $userProvider,
+        SessionStorageInterface $sessionStorage,
+        array $options = []
+    )
+    {
+        $this->userProvider = $userProvider;
+        $this->sessionStorage = $sessionStorage;
+
+        $optionResolver = new OptionsResolver([
+            Option::string('login_path', '/login')->min(1),
+            Option::string('login_key', 'login')->min(1),
+            Option::string('password_key', 'password')->min(1),
+            Option::mixed('on_failure')->validator(function ($value) {
+                return is_callable($value) || $value === null;
+            })->setOptional(null),
+        ]);
+
+        $options = $optionResolver->resolve($options);
+        $this->loginPath = '/'.ltrim($options['login_path'], '/');
+        $this->loginKey = $options['login_key'];
+        $this->passwordKey = $options['password_key'];
+        $this->onFailure = $options['on_failure'];
+    }
+
+    /**
+     * @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);
+            }
+        }
+
+        $path = $request->getUri()->getPath();
+        $method = $request->getMethod();
+
+        if ($path === $this->loginPath && $method === 'GET') {
+            return null;
+        }
+
+        if ($path !== $this->loginPath) {
+            throw new AuthenticationException('Authentication required.');
+        }
+
+        if ($method !== 'POST') {
+            throw new AuthenticationException('Login form must be submitted using POST.');
+        }
+
+        list($login, $password) = self::extractCredentials($request, $this->loginKey, $this->passwordKey);
+        if (empty($login) || empty($password)) {
+            throw new InvalidCredentialsException("Credentials cannot be empty.");
+        }
+        $this->sessionStorage->put(self::LAST_USERNAME, $login);
+
+        /**
+         * @var PasswordAuthenticatedUserInterface|UserInterface|null $user
+         */
+        $user = $this->userProvider->findByIdentifier($login);
+        if (!$user instanceof UserInterface) {
+            throw new UserNotFoundException("Invalid username or password.");
+        }
+
+        if (!$user instanceof PasswordAuthenticatedUserInterface) {
+            throw new AuthenticationException("The resolved user does not support password authentication.");
+        }
+
+        if (!$this->userProvider->checkPassword($user, $password)) {
+            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
+    {
+        if ($exception) {
+            $this->sessionStorage->put(self::AUTHENTICATION_ERROR, $exception->getMessage());
+            $request = $request->withAttribute(self::AUTHENTICATION_ERROR, $exception->getMessage());
+        }
+
+        if (!is_callable($this->onFailure)) {
+            $response = $responseFactory->createResponse(302);
+            return $response->withHeader('Location', $this->loginPath);
+        }
+        return ($this->onFailure)($request, $responseFactory, $exception);
+    }
+
+    private static function extractCredentials(ServerRequestInterface $request, string $keyLogin, string $keyPassword): array
+    {
+        $data = $request->getParsedBody();
+        $login = $data[$keyLogin] ?? '';
+        $pass = $data[$keyPassword] ?? '';
+        return [
+            $login,
+            $pass
+        ];
+    }
+
+}

+ 77 - 0
src/Handler/TokenAuthHandler.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Michel\Auth\Handler;
+
+use Michel\Auth\AuthHandlerInterface;
+use Michel\Auth\AuthIdentity;
+use Michel\Auth\Exception\AuthenticationException;
+use Michel\Auth\Exception\InvalidCredentialsException;
+use Michel\Auth\Exception\UserNotFoundException;
+use Michel\Auth\UserInterface;
+use Michel\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;
+    }
+
+
+    /**
+     * @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);
+    }
+
+}

+ 27 - 0
src/Helper/IpHelper.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace Michel\Auth\Helper;
+
+use Psr\Http\Message\ServerRequestInterface;
+
+final class IpHelper
+{
+    public static function getIpFromRequest(ServerRequestInterface $request): string
+    {
+        $serverParams = $request->getServerParams();
+        if (array_key_exists('HTTP_CLIENT_IP', $serverParams)) {
+            return $serverParams['HTTP_CLIENT_IP'];
+        }
+
+        if (array_key_exists('HTTP_X_FORWARDED_FOR', $serverParams)) {
+            return $serverParams['HTTP_X_FORWARDED_FOR'];
+        }
+
+        if (array_key_exists('REMOTE_ADDR', $serverParams)) {
+            return $serverParams['REMOTE_ADDR'];
+        }
+
+        return '';
+    }
+
+}

+ 74 - 0
src/MichelPackage/MichelAuthPackage.php

@@ -0,0 +1,74 @@
+<?php
+
+namespace Michel\Auth\MichelPackage;
+
+use Michel\Auth\Command\AuthPasswordHashCommand;
+use Michel\Auth\Handler\FormAuthHandler;
+use Michel\Auth\Middlewares\AuthMiddleware;
+use Michel\Auth\UserProviderInterface;
+use Michel\Package\PackageInterface;
+use Michel\Session\Storage\SessionStorageInterface;
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Log\LoggerInterface;
+
+class MichelAuthPackage implements PackageInterface
+{
+
+    public function getDefinitions(): array
+    {
+        return [
+            AuthMiddleware::class => static function (ContainerInterface $container) {
+                return new AuthMiddleware(
+                    $container->get(FormAuthHandler::class),
+                    $container->get(ResponseFactoryInterface::class),
+                    $container->get(LoggerInterface::class)
+                );
+            },
+            FormAuthHandler::class => static function (ContainerInterface $container) {
+                return new FormAuthHandler(
+                    $container->get(UserProviderInterface::class),
+                    $container->get(SessionStorageInterface::class),
+                    [
+                        'login_path' => $container->get('auth.form_login_path'),
+                        'login_key' => $container->get('auth.form_login_key'),
+                        'password_key' => $container->get('auth.form_password_key'),
+                        'on_failure' => $container->get('auth.form_on_failure')
+                    ]
+                );
+            },
+        ];
+    }
+
+    public function getParameters(): array
+    {
+        return [
+            'auth.form_login_path' => '/login',
+            'auth.form_login_key' => '_username',
+            'auth.form_password_key' => '_password',
+            'auth.form_on_failure' => null
+        ];
+    }
+
+    public function getRoutes(): array
+    {
+        return [];
+    }
+
+    public function getControllerSources(): array
+    {
+        return [];
+    }
+
+    public function getListeners(): array
+    {
+        return [];
+    }
+
+    public function getCommandSources(): array
+    {
+        return [
+            AuthPasswordHashCommand::class
+        ];
+    }
+}

+ 78 - 0
src/Middlewares/AuthMiddleware.php

@@ -0,0 +1,78 @@
+<?php
+
+namespace Michel\Auth\Middlewares;
+
+use Michel\Auth\AuthHandlerInterface;
+use Michel\Auth\AuthIdentity;
+use Michel\Auth\Exception\AuthenticationException;
+use Michel\Auth\Helper\IpHelper;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use Psr\Log\LoggerInterface;
+
+final class AuthMiddleware implements MiddlewareInterface
+{
+    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) {
+                return $handler->handle($request);
+            }
+            $user = $authIdentity->getUser();
+            $request = $request->withAttribute("user", $user);
+            if ($authIdentity->isNewLogin()) {
+                $this->log('info',
+                    '[{handler}] User authenticated successfully (identifier: {identifier}, ip: {ip})',
+                    [
+                        'handler'    => $handlerName,
+                        'identifier' => $user->getUserIdentifier(),
+                        'ip'         => IpHelper::getIpFromRequest($request),
+                    ]
+                );
+            }
+            return $handler->handle($request);
+        }catch (AuthenticationException $exception) {
+            $this->log(
+                'warning',
+                '[{handler}] Authentication failed (ip: {ip}, path: {path}) : {message}',
+                [
+                    'handler' => $handlerName,
+                    'message' => $exception->getMessage(),
+                    'ip'      => IpHelper::getIpFromRequest($request),
+                    'path'    => $request->getUri()->getPath(),
+                ]
+            );
+            return $this->authHandler->onFailure($request, $this->responseFactory, $exception);
+        }
+    }
+
+
+    private function log(string $level, string $message, array $context = []): void
+    {
+        if ($this->logger === null) {
+            return;
+        }
+        $this->logger->log($level, $message, $context);
+    }
+}

+ 44 - 0
src/Password/PasswordTrait.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace Michel\Auth\Password;
+
+use Michel\Auth\PasswordAuthenticatedUserInterface;
+
+trait PasswordTrait
+{
+    private string $algorithm = PASSWORD_BCRYPT;
+    private int $cost = 10;
+
+    public function hashPassword(string $plainPassword): string
+    {
+        return password_hash($plainPassword, $this->algorithm, ['cost' => $this->cost]);
+    }
+
+    public function isPasswordValid(PasswordAuthenticatedUserInterface $user, string $plainPassword): bool
+    {
+        return password_verify($plainPassword, $user->getPassword());
+    }
+
+    public function setCost(int $cost): void
+    {
+        if ($cost < 4 || $cost > 12) {
+            throw new \InvalidArgumentException('Cost must be in the range of 4-31.');
+        }
+        $this->cost = $cost;
+    }
+
+    public function setAlgorithm(string $algorithm): void
+    {
+        if (!password_algos() || !in_array($algorithm, password_algos())) {
+            throw new \InvalidArgumentException(
+                sprintf(
+                    'Invalid password hashing algorithm "%s". Available algorithms: %s.',
+                    $algorithm,
+                    implode(', ', password_algos())
+                )
+            );
+        }
+
+        $this->algorithm = $algorithm;
+    }
+}

+ 10 - 0
src/PasswordAuthenticatedUserInterface.php

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

+ 8 - 0
src/UserInterface.php

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

+ 12 - 0
src/UserProviderInterface.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace Michel\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;
+    public function hashPassword(string $plainPassword): string;
+}