diff --git a/CHANGELOG.md b/CHANGELOG.md index ed71fb1..b8b50e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Release Notes -## [Unreleased](https://github.com/agungsugiarto/codeigniter4-authentication/compare/v2.0.0...2.x) +## [Unreleased](https://github.com/agungsugiarto/codeigniter4-authentication/compare/v2.0.1...2.x) + +## [v2.0.1 (2022-09-28)](https://github.com/agungsugiarto/codeigniter4-authentication/compare/v2.0.0...v2.0.1) +### Added +* Implement HTTP Basic Authentication provides a quick way to authenticate users by @agungsugiarto in [#32](https://github.com/agungsugiarto/codeigniter4-authentication/pull/32) ## [v2.0.0 (2022-04-11)](https://github.com/agungsugiarto/codeigniter4-authentication/compare/v1.0.8...v2.0.0) ### What's Changed diff --git a/composer.json b/composer.json index eadd3d0..ce0d5f8 100644 --- a/composer.json +++ b/composer.json @@ -22,8 +22,7 @@ }, "require-dev": { "fakerphp/faker": "^1.13", - "phpunit/phpunit": "^9.1", - "laminas/laminas-coding-standard": "^2.1" + "phpunit/phpunit": "^9.1" }, "autoload": { "psr-4": { @@ -49,8 +48,6 @@ "minimum-stability": "dev", "prefer-stable": true, "scripts": { - "cs-check": "phpcs", - "cs-fix": "phpcbf", "test": "phpunit" } } diff --git a/docs/en/authentication.md b/docs/en/authentication.md index 668c2fc..70c2b4b 100644 --- a/docs/en/authentication.md +++ b/docs/en/authentication.md @@ -10,6 +10,8 @@ - [Manually Authenticating Users](#authenticating-users) - [Remembering Users](#remembering-users) - [Other Authentication Methods](#other-authentication-methods) +- [HTTP Basic Authentication](#http-basic-authentication) + - [Stateless HTTP Basic Authentication](#stateless-http-basic-authentication) - [Logging Out](#logging-out) - [Password Confirmation](#password-confirmation) - [Configuration](#password-confirmation-configuration) @@ -104,9 +106,10 @@ Open `app\Config\FIlters` see property with `aliases` and add this array to regi ```php public $aliases = [ // ... - 'auth' => \Fluent\Auth\Filters\AuthenticationFilter::class, - 'can' => \Fluent\Auth\Filters\AuthorizeFilter::class, - 'confirm' => [ + 'auth' => \Fluent\Auth\Filters\AuthenticationFilter::class, + 'auth.basic' => \Fluent\Auth\Filters\AuthenticationBasicFilter::class, + 'can' => \Fluent\Auth\Filters\AuthorizeFilter::class, + 'confirm' => [ \Fluent\Auth\Filters\AuthenticationFilter::class, \Fluent\Auth\Filters\ConfirmPasswordFilter::class, ], @@ -324,6 +327,43 @@ You may pass a boolean value as the second argument to the `loginById` method. T Auth::loginById(1, $remember = true); ``` + +## HTTP Basic Authentication + +[HTTP Basic Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) provides a quick way to authenticate users of your application without setting up a dedicated "login" page. To get started, attach the `auth.basic` filter to a route: + +```php +$routes->group('basic', ['filter' => "auth.basic:web,email,basic"], function ($routes) { + $routes->get('treasure', function () { + // Only authenticated users may access this route... + }); +}); +``` + +Once the filter has been attached to the route, you will automatically be prompted for credentials when accessing the route in your browser. By default, the `auth.basic` filter will assume the `email` column on your `users` database table is the user's "username". + + +#### A Note On FastCGI + +If you are using PHP FastCGI and Apache to serve your Laravel application, HTTP Basic authentication may not work correctly. To correct these problems, the following lines may be added to your application's `.htaccess` file: + +```apache +RewriteCond %{HTTP:Authorization} ^(.+)$ +RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] +``` + + +### Stateless HTTP Basic Authentication + +You may also use HTTP Basic Authentication without setting a user identifier cookie in the session. This is primarily helpful if you choose to use HTTP Authentication to authenticate requests to your application's API. To accomplish this calls the `onceBasic` method. If no response is returned by the `onceBasic` method, the request may be passed further into the application: +```php +$routes->group('onceBasic', ['filter' => "auth.basic:web,email,onceBasic"], function ($routes) { + $routes->get('treasure', function () { + // Only authenticated users may access this route... + }); +}); +``` + ## Logging Out To manually log users out of your application, you may use the `logout` method provided by the `Auth` facade. This will remove the authentication information from the user's session so that subsequent requests are not authenticated. diff --git a/src/Adapters/SessionAdapter.php b/src/Adapters/SessionAdapter.php index 3ee67e2..3a96e81 100644 --- a/src/Adapters/SessionAdapter.php +++ b/src/Adapters/SessionAdapter.php @@ -9,10 +9,12 @@ use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Session\SessionInterface; use Exception; +use Fluent\Auth\Contracts\AuthenticationBasicInterface; use Fluent\Auth\Contracts\AuthenticationInterface; use Fluent\Auth\Contracts\AuthenticatorInterface; use Fluent\Auth\Contracts\UserProviderInterface; use Fluent\Auth\CookieRecaller; +use Fluent\Auth\Exceptions\AuthenticationException; use Fluent\Auth\Traits\GuardHelperTrait; use function bin2hex; @@ -20,7 +22,7 @@ use function random_bytes; use function sha1; -class SessionAdapter implements AuthenticationInterface +class SessionAdapter implements AuthenticationBasicInterface, AuthenticationInterface { use GuardHelperTrait; @@ -121,6 +123,109 @@ public function validate(array $credentials): bool return $this->hasValidCredentials($user, $credentials); } + /** + * {@inheritdoc} + */ + public function basic($field = 'email', $extraConditions = []) + { + if ($this->check()) { + return; + } + + // If a username is set on the HTTP basic request, we will return out without + // interrupting the request lifecycle. Otherwise, we'll need to generate a + // request indicating that the given credentials were invalid for login. + if ($this->attemptBasic($field, $extraConditions)) { + return; + } + + throw new AuthenticationException('Invalid credentials.'); + } + + /** + * {@inheritdoc} + */ + public function onceBasic($field = 'email', $extraConditions = []) + { + $credentials = $this->basicCredentials($field); + + if (! $this->once(array_merge($credentials, $extraConditions))) { + throw new AuthenticationException('Invalid credentials.'); + } + } + + /** + * {@inheritdoc} + */ + public function once(array $credentials = []) + { + Events::trigger('fireAttemptEvent', $credentials); + + if ($this->validate($credentials)) { + $this->setUser($this->lastAttempted); + + return true; + } + + return false; + } + + /** + * Attempt to authenticate using basic authentication. + * + * @param string $field + * @param array $extraConditions + * @return bool + */ + protected function attemptBasic($field, $extraConditions = []) + { + return $this->attempt(array_merge( + $this->basicCredentials($field), + $extraConditions + )); + } + + /** + * Get the credential array for an HTTP Basic request. + * + * @param string $field + * @return array + */ + protected function basicCredentials($field) + { + if (! $this->request->hasHeader('Authorization')) { + return []; + } + + $authHeaders = [$this->request->header('Authorization')->getValue()]; + + if (1 !== count($authHeaders)) { + return []; + } + + $authHeader = array_shift($authHeaders); + + if (! preg_match('/Basic (?P.+)/', $authHeader, $match)) { + return []; + } + + $decodedCredentials = base64_decode($match['credentials'], true); + + if (false === $decodedCredentials) { + return []; + } + + $credentialParts = explode(':', $decodedCredentials, 2); + + if (2 !== count($credentialParts)) { + return []; + } + + [$username, $password] = $credentialParts; + + return [$field => $username, 'password' => $password]; + } + /** * {@inheritdoc} */ diff --git a/src/Contracts/AuthFactoryInterface.php b/src/Contracts/AuthFactoryInterface.php index a4877cd..53a4351 100644 --- a/src/Contracts/AuthFactoryInterface.php +++ b/src/Contracts/AuthFactoryInterface.php @@ -27,7 +27,7 @@ public function getDefaultUserProvider(); * Attempt to get the guard from the local cache. * * @param string|null $name - * @return AuthenticationInterface + * @return AuthenticationBasicInterface|AuthenticationInterface */ public function guard($name = null); diff --git a/src/Contracts/AuthenticationBasicInterface.php b/src/Contracts/AuthenticationBasicInterface.php new file mode 100644 index 0000000..294d580 --- /dev/null +++ b/src/Contracts/AuthenticationBasicInterface.php @@ -0,0 +1,32 @@ +auth = Services::auth(); + } + + /** + * {@inheritdoc} + */ + public function before(RequestInterface $request, $arguments = null) + { + [$guard, $field, $method] = $arguments; + + $this->auth->guard($guard)->{$method}($field); + } + + /** + * {@inheritdoc} + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + } +} \ No newline at end of file diff --git a/src/Helpers/auth_helper.php b/src/Helpers/auth_helper.php index 55cc1c4..22d0267 100644 --- a/src/Helpers/auth_helper.php +++ b/src/Helpers/auth_helper.php @@ -1,6 +1,7 @@ aliases['auth'] = AuthenticationFilter::class; + $filters->aliases['auth.basic'] = AuthenticationBasicFilter::class; Factories::injectMock('filters', 'filters', $filters); $routes = Services::routes(); @@ -46,6 +49,18 @@ protected function setUp(): void }); }); + $routes->group('basic', ['filter' => "auth.basic:web,email,basic"], function ($routes) { + $routes->get('treasure', function () { + return 'you found gems'; + }); + }); + + $routes->group('onceBasic', ['filter' => "auth.basic:web,email,onceBasic"], function ($routes) { + $routes->get('treasure', function () { + return 'you found gems'; + }); + }); + Services::injectMock('routes', $routes); } @@ -61,6 +76,76 @@ protected function setRequestHeader(string $token) $request->setHeader('Authorization', 'Token ' . $token); } + public function testBasicFilter() + { + $user = fake(UserModel::class); + + $result = $this + ->withHeaders([ + 'Authorization' => 'Basic ' . base64_encode("{$user->email}:secret"), + ]) + ->call('get', 'basic/treasure'); + + $result->assertOK(); + $result->assertSee('you found gems'); + $this->assertNotNull($this->auth->user()); + $this->assertTrue(session()->has($this->auth->getSessionName())); + $this->assertEquals(session($this->auth->getSessionName()), $this->auth->id()); + } + + public function testFailedBasicFilter() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + $user = fake(UserModel::class); + + $result = $this + ->withHeaders([ + 'Authorization' => 'Basic ' . base64_encode("{$user->email}:secrets"), + ]) + ->call('get', 'basic/treasure'); + + $result->assertNotOK(); + $result->assertDontSee('you found gems'); + $this->assertNull($this->auth->user()); + } + + public function testOnceBasicFilter() + { + $user = fake(UserModel::class); + + $result = $this + ->withHeaders([ + 'Authorization' => 'Basic ' . base64_encode("{$user->email}:secret"), + ]) + ->call('get', 'onceBasic/treasure'); + + $result->assertOK(); + $result->assertSee('you found gems'); + $this->assertNotNull($this->auth->user()); + $this->assertFalse(session()->has($this->auth->getSessionName())); + $this->assertNotEquals(session($this->auth->getSessionName()), $this->auth->id()); + } + + public function testFailedOnceBasicFilter() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + $user = fake(UserModel::class); + + $result = $this + ->withHeaders([ + 'Authorization' => 'Basic ' . base64_encode("{$user->email}:secrets"), + ]) + ->call('get', 'onceBasic/treasure'); + + $result->assertNotOK(); + $result->assertDontSee('you found gems'); + $this->assertNull($this->auth->user()); + } + public function testDefaultGuardFilter() { $this->expectException(AuthenticationException::class);