diff --git a/composer.json b/composer.json index b75d283..e8f28ac 100644 --- a/composer.json +++ b/composer.json @@ -1,4 +1,5 @@ { + "version": "3.1.1", "name": "ghostzero/bitinflow-accounts", "description": "PHP bitinflow Accounts API Client for Laravel 5+", "license": "MIT", @@ -9,7 +10,7 @@ } ], "require": { - "php": ">=7.2", + "php": "^7.2", "ext-json": "*", "illuminate/support": "^8.0", "illuminate/console": "^8.0", diff --git a/src/GhostZero/BitinflowAccounts/ApiTokenCookieFactory.php b/src/GhostZero/BitinflowAccounts/ApiTokenCookieFactory.php new file mode 100644 index 0000000..daeaf77 --- /dev/null +++ b/src/GhostZero/BitinflowAccounts/ApiTokenCookieFactory.php @@ -0,0 +1,82 @@ +config = $config; + $this->encrypter = $encrypter; + } + + /** + * Create a new API token cookie. + * + * @param mixed $userId + * @param string $csrfToken + * @return Cookie + */ + public function make($userId, string $csrfToken): Cookie + { + $config = $this->config->get('session'); + + $expiration = Carbon::now()->addMinutes($config['lifetime']); + + return new Cookie( + BitinflowAccounts::cookie(), + $this->createToken($userId, $csrfToken, $expiration), + $expiration, + $config['path'], + $config['domain'], + $config['secure'], + true, + false, + $config['same_site'] ?? null + ); + } + + /** + * Create a new JWT token for the given user ID and CSRF token. + * + * @param mixed $userId + * @param string $csrfToken + * @param Carbon $expiration + * @return string + */ + protected function createToken($userId, string $csrfToken, Carbon $expiration): string + { + return JWT::encode([ + 'sub' => $userId, + 'csrf' => $csrfToken, + 'expiry' => $expiration->getTimestamp(), + ], $this->encrypter->getKey()); + } +} \ No newline at end of file diff --git a/src/GhostZero/BitinflowAccounts/Auth/TokenGuard.php b/src/GhostZero/BitinflowAccounts/Auth/TokenGuard.php new file mode 100644 index 0000000..88d2f2e --- /dev/null +++ b/src/GhostZero/BitinflowAccounts/Auth/TokenGuard.php @@ -0,0 +1,229 @@ +provider = $provider; + $this->encrypter = $encrypter; + $this->jwtParser = $jwtParser; + } + + /** + * Get the user for the incoming request. + * + * @param Request $request + * @return mixed + * @throws BindingResolutionException + * @throws Throwable + */ + public function user(Request $request): ?Authenticatable + { + if ($request->bearerToken()) { + return $this->authenticateViaBearerToken($request); + } elseif ($request->cookie(BitinflowAccounts::cookie())) { + return $this->authenticateViaCookie($request); + } + + return null; + } + + /** + * Authenticate the incoming request via the Bearer token. + * + * @param Request $request + * @return Authenticatable + * @throws BindingResolutionException + * @throws Throwable + */ + protected function authenticateViaBearerToken(Request $request): ?Authenticatable + { + if (!$token = $this->validateRequestViaBearerToken($request)) { + return null; + } + + // If the access token is valid we will retrieve the user according to the user ID + // associated with the token. We will use the provider implementation which may + // be used to retrieve users from Eloquent. Next, we'll be ready to continue. + /** @var Authenticatable|HasBitinflowTokens $user */ + $user = $this->provider->retrieveById( + $request->attributes->get('oauth_user_id') ?: null + ); + + if (!$user) { + return null; + } + + return $token ? $user->withBitinflowAccessToken($token) : null; + } + + /** + * Authenticate and get the incoming request via the Bearer token. + * + * @param Request $request + * @return stdClass|null + * @throws BindingResolutionException + * @throws Throwable + */ + protected function validateRequestViaBearerToken(Request $request): ?stdClass + { + try { + $decoded = $this->jwtParser->decode($request); + + $request->attributes->set('oauth_access_token_id', $decoded->jti); + $request->attributes->set('oauth_client_id', $decoded->aud); + $request->attributes->set('oauth_client_trusted', $decoded->client->trusted); + $request->attributes->set('oauth_user_id', $decoded->sub); + $request->attributes->set('oauth_scopes', $decoded->scopes); + + return $decoded; + } catch (AuthenticationException $e) { + $request->headers->set('Authorization', '', true); + + Container::getInstance()->make( + ExceptionHandler::class + )->report($e); + + return null; + } + } + + /** + * Authenticate the incoming request via the token cookie. + * + * @param Request $request + * @return mixed + */ + protected function authenticateViaCookie(Request $request) + { + if (!$token = $this->getTokenViaCookie($request)) { + return null; + } + + // If this user exists, we will return this user and attach a "transient" token to + // the user model. The transient token assumes it has all scopes since the user + // is physically logged into the application via the application's interface. + /** @var Authenticatable|HasBitinflowTokens $user */ + if ($user = $this->provider->retrieveById($token['sub'])) { + return $user->withBitinflowAccessToken((object)['scopes' => '*']); + } + + return null; + } + + /** + * Get the token cookie via the incoming request. + * + * @param Request $request + * @return array|null + */ + protected function getTokenViaCookie(Request $request): ?array + { + // If we need to retrieve the token from the cookie, it'll be encrypted so we must + // first decrypt the cookie and then attempt to find the token value within the + // database. If we can't decrypt the value we'll bail out with a null return. + try { + $token = $this->decodeJwtTokenCookie($request); + } catch (Exception $e) { + return null; + } + + // We will compare the CSRF token in the decoded API token against the CSRF header + // sent with the request. If they don't match then this request isn't sent from + // a valid source and we won't authenticate the request for further handling. + if (!BitinflowAccounts::$ignoreCsrfToken && (!$this->validCsrf($token, $request) || + time() >= $token['expiry'])) { + return null; + } + + return $token; + } + + /** + * Decode and decrypt the JWT token cookie. + * + * @param Request $request + * @return array + */ + protected function decodeJwtTokenCookie(Request $request): array + { + return (array)JWT::decode( + CookieValuePrefix::remove($this->encrypter->decrypt($request->cookie(BitinflowAccounts::cookie()), BitinflowAccounts::$unserializesCookies)), + $this->encrypter->getKey(), + ['HS256'] + ); + } + + /** + * Determine if the CSRF / header are valid and match. + * + * @param array $token + * @param Request $request + * @return bool + */ + protected function validCsrf(array $token, Request $request): bool + { + return isset($token['csrf']) && hash_equals( + $token['csrf'], (string)$this->getTokenFromRequest($request) + ); + } + + /** + * Get the CSRF token from the request. + * + * @param Request $request + * @return string + */ + protected function getTokenFromRequest(Request $request): string + { + $token = $request->header('X-CSRF-TOKEN'); + + if (!$token && $header = $request->header('X-XSRF-TOKEN')) { + $token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized())); + } + + return $token; + } + + /** + * Determine if the cookie contents should be serialized. + * + * @return bool + */ + public static function serialized(): bool + { + return EncryptCookies::serialized('XSRF-TOKEN'); + } +} \ No newline at end of file diff --git a/src/GhostZero/BitinflowAccounts/Auth/UserProvider.php b/src/GhostZero/BitinflowAccounts/Auth/UserProvider.php new file mode 100644 index 0000000..769aee7 --- /dev/null +++ b/src/GhostZero/BitinflowAccounts/Auth/UserProvider.php @@ -0,0 +1,86 @@ +provider = $provider; + $this->providerName = $providerName; + } + + /** + * {@inheritdoc} + */ + public function retrieveById($identifier) + { + return $this->provider->retrieveById($identifier); + } + + /** + * {@inheritdoc} + */ + public function retrieveByToken($identifier, $token) + { + return $this->provider->retrieveByToken($identifier, $token); + } + + /** + * {@inheritdoc} + */ + public function updateRememberToken(Authenticatable $user, $token) + { + $this->provider->updateRememberToken($user, $token); + } + + /** + * {@inheritdoc} + */ + public function retrieveByCredentials(array $credentials) + { + return $this->provider->retrieveByCredentials($credentials); + } + + /** + * {@inheritdoc} + */ + public function validateCredentials(Authenticatable $user, array $credentials) + { + return $this->provider->validateCredentials($user, $credentials); + } + + /** + * Get the name of the user provider. + * + * @return string + */ + public function getProviderName() + { + return $this->providerName; + } +} diff --git a/src/GhostZero/BitinflowAccounts/BitinflowAccounts.php b/src/GhostZero/BitinflowAccounts/BitinflowAccounts.php index 435294f..180a66f 100644 --- a/src/GhostZero/BitinflowAccounts/BitinflowAccounts.php +++ b/src/GhostZero/BitinflowAccounts/BitinflowAccounts.php @@ -32,6 +32,27 @@ class BitinflowAccounts private static $baseUrl = 'https://accounts.bitinflow.com/api/'; + /** + * The name for API token cookies. + * + * @var string + */ + public static $cookie = 'bitinflow_token'; + + /** + * Indicates if Bitinflow Accounts should ignore incoming CSRF tokens. + * + * @var bool + */ + public static $ignoreCsrfToken = false; + + /** + * Indicates if Bitinflow Accounts should unserializes cookies. + * + * @var bool + */ + public static $unserializesCookies = false; + /** * Guzzle is used to make http requests. * @var \GuzzleHttp\Client @@ -90,6 +111,49 @@ class BitinflowAccounts ]); } + /** + * Get or set the name for API token cookies. + * + * @param string|null $cookie + * @return string|static + */ + public static function cookie($cookie = null) + { + if (is_null($cookie)) { + return static::$cookie; + } + + static::$cookie = $cookie; + + return new static; + } + + /** + * Set the current user for the application with the given scopes. + * + * @param \Illuminate\Contracts\Auth\Authenticatable|Traits\HasBitinflowTokens $user + * @param array $scopes + * @param string $guard + * @return \Illuminate\Contracts\Auth\Authenticatable + */ + public static function actingAs($user, $scopes = [], $guard = 'api') + { + + $user->withBitinflowAccessToken((object) [ + 'scopes' => $scopes + ]); + + if (isset($user->wasRecentlyCreated) && $user->wasRecentlyCreated) { + $user->wasRecentlyCreated = false; + } + + app('auth')->guard($guard)->setUser($user); + + app('auth')->shouldUse($guard); + + return $user; + } + /** * @param string $baseUrl * diff --git a/src/GhostZero/BitinflowAccounts/Helpers/JwtParser.php b/src/GhostZero/BitinflowAccounts/Helpers/JwtParser.php new file mode 100644 index 0000000..ceb1d84 --- /dev/null +++ b/src/GhostZero/BitinflowAccounts/Helpers/JwtParser.php @@ -0,0 +1,41 @@ +bearerToken(), + $this->getOauthPublicKey(), + self::ALLOWED_ALGORITHMS + ); + } catch (Throwable $exception) { + throw (new AuthenticationException()); + } + } + + private function getOauthPublicKey() + { + return file_get_contents(__DIR__ . '/../../../../oauth-public.key'); + } +} \ No newline at end of file diff --git a/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckClientCredentials.php b/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckClientCredentials.php index 14f0b2e..e9a0e9e 100644 --- a/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckClientCredentials.php +++ b/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckClientCredentials.php @@ -2,81 +2,31 @@ namespace GhostZero\BitinflowAccounts\Http\Middleware; -use Closure; -use Firebase\JWT\JWT; -use Illuminate\Auth\AuthenticationException; -use Illuminate\Http\Request; use GhostZero\BitinflowAccounts\Exceptions\MissingScopeException; use stdClass; -use Throwable; -class CheckClientCredentials +class CheckClientCredentials extends CheckCredentials { - public const ALLOWED_ALGORITHMS = ['RS256']; - - /** - * Handle an incoming request. - * - * @param Request $request - * @param Closure $next - * @param mixed ...$scopes - * - * @throws AuthenticationException|MissingScopeException - * - * @return mixed - */ - public function handle($request, Closure $next, ...$scopes) - { - JWT::$leeway = 60; - - try { - $decoded = JWT::decode( - $request->bearerToken(), - $this->getOauthPublicKey(), - self::ALLOWED_ALGORITHMS - ); - } catch (Throwable $exception) { - throw new AuthenticationException(); - } - - $request->attributes->set('oauth_access_token_id', $decoded->jti); - $request->attributes->set('oauth_client_id', $decoded->aud); - $request->attributes->set('oauth_client_trusted', $decoded->client->trusted); - $request->attributes->set('oauth_user_id', $decoded->sub); - $request->attributes->set('oauth_scopes', $decoded->scopes); - - $this->validateScopes($decoded, $scopes); - - return $next($request); - } - - private function getOauthPublicKey() - { - return file_get_contents(__DIR__ . '/../../../../../oauth-public.key'); - } - /** * Validate token credentials. * * @param stdClass $token * @param array $scopes * + * @return void * @throws MissingScopeException * - * @return void */ protected function validateScopes(stdClass $token, array $scopes) { - if (empty($scopes) || in_array('*', $token->scopes)) { + if (in_array('*', $token->scopes)) { return; } foreach ($scopes as $scope) { - if (in_array($scope, $token->scopes)) { - return; + if (!in_array($scope, $token->scopes)) { + throw new MissingScopeException($scopes); } } - - throw new MissingScopeException($scopes); } } \ No newline at end of file diff --git a/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckClientCredentialsForAnyScope.php b/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckClientCredentialsForAnyScope.php new file mode 100644 index 0000000..2e31e30 --- /dev/null +++ b/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckClientCredentialsForAnyScope.php @@ -0,0 +1,34 @@ +scopes)) { + return; + } + + foreach ($scopes as $scope) { + if (in_array($scope, $token->scopes)) { + return; + } + } + + throw new MissingScopeException($scopes); + } +} \ No newline at end of file diff --git a/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckCredentials.php b/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckCredentials.php new file mode 100644 index 0000000..f0a9a1b --- /dev/null +++ b/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckCredentials.php @@ -0,0 +1,46 @@ +getJwtParser()->decode($request); + + $request->attributes->set('oauth_access_token_id', $decoded->jti); + $request->attributes->set('oauth_client_id', $decoded->aud); + //$request->attributes->set('oauth_client_trusted', $decoded->client->trusted); + $request->attributes->set('oauth_user_id', $decoded->sub); + $request->attributes->set('oauth_scopes', $decoded->scopes); + + $this->validateScopes($decoded, $scopes); + + return $next($request); + } + + private function getJwtParser(): JwtParser + { + return app(JwtParser::class); + } + + abstract protected function validateScopes(stdClass $token, array $scopes); +} \ No newline at end of file diff --git a/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckForAnyScope.php b/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckForAnyScope.php new file mode 100644 index 0000000..f601faa --- /dev/null +++ b/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckForAnyScope.php @@ -0,0 +1,39 @@ +user() || ! $request->user()->bitinflowToken()) { + throw new AuthenticationException; + } + + foreach ($scopes as $scope) { + if ($request->user()->bitinflowTokenCan($scope)) { + return $next($request); + } + } + + throw new MissingScopeException($scopes); + } +} \ No newline at end of file diff --git a/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckScopes.php b/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckScopes.php new file mode 100644 index 0000000..96e446d --- /dev/null +++ b/src/GhostZero/BitinflowAccounts/Http/Middleware/CheckScopes.php @@ -0,0 +1,37 @@ +user() || ! $request->user()->bitinflowToken()) { + throw new AuthenticationException; + } + + foreach ($scopes as $scope) { + if (! $request->user()->bitinflowTokenCan($scope)) { + throw new MissingScopeException($scope); + } + } + + return $next($request); + } +} diff --git a/src/GhostZero/BitinflowAccounts/Http/Middleware/CreateFreshApiToken.php b/src/GhostZero/BitinflowAccounts/Http/Middleware/CreateFreshApiToken.php new file mode 100644 index 0000000..bf4c6ab --- /dev/null +++ b/src/GhostZero/BitinflowAccounts/Http/Middleware/CreateFreshApiToken.php @@ -0,0 +1,117 @@ +cookieFactory = $cookieFactory; + } + + /** + * Handle an incoming request. + * + * @param Request $request + * @param Closure $next + * @param string|null $guard + * @return mixed + */ + public function handle($request, Closure $next, $guard = null) + { + $this->guard = $guard; + + $response = $next($request); + + if ($this->shouldReceiveFreshToken($request, $response)) { + $response->withCookie($this->cookieFactory->make( + $request->user($this->guard)->getAuthIdentifier(), $request->session()->token() + )); + } + + return $response; + } + + /** + * Determine if the given request should receive a fresh token. + * + * @param Request $request + * @param Response $response + * @return bool + */ + protected function shouldReceiveFreshToken($request, $response): bool + { + return $this->requestShouldReceiveFreshToken($request) && + $this->responseShouldReceiveFreshToken($response); + } + + /** + * Determine if the request should receive a fresh token. + * + * @param Request $request + * @return bool + */ + protected function requestShouldReceiveFreshToken($request) + { + return $request->isMethod('GET') && $request->user($this->guard); + } + + /** + * Determine if the response should receive a fresh token. + * + * @param Response $response + * @return bool + */ + protected function responseShouldReceiveFreshToken($response) + { + return ($response instanceof Response || + $response instanceof JsonResponse) && + ! $this->alreadyContainsToken($response); + } + + /** + * Determine if the given response already contains an API token. + * + * This avoids us overwriting a just "refreshed" token. + * + * @param Response $response + * @return bool + */ + protected function alreadyContainsToken($response): bool + { + foreach ($response->headers->getCookies() as $cookie) { + if ($cookie->getName() === BitinflowAccounts::cookie()) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/GhostZero/BitinflowAccounts/Providers/BitinflowAccountsServiceProvider.php b/src/GhostZero/BitinflowAccounts/Providers/BitinflowAccountsServiceProvider.php index af6431a..ffdb407 100644 --- a/src/GhostZero/BitinflowAccounts/Providers/BitinflowAccountsServiceProvider.php +++ b/src/GhostZero/BitinflowAccounts/Providers/BitinflowAccountsServiceProvider.php @@ -4,7 +4,12 @@ declare(strict_types=1); namespace GhostZero\BitinflowAccounts\Providers; +use GhostZero\BitinflowAccounts\Auth\TokenGuard; +use GhostZero\BitinflowAccounts\Auth\UserProvider; use GhostZero\BitinflowAccounts\BitinflowAccounts; +use GhostZero\BitinflowAccounts\Helpers\JwtParser; +use Illuminate\Auth\RequestGuard; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\ServiceProvider; class BitinflowAccountsServiceProvider extends ServiceProvider @@ -33,6 +38,8 @@ class BitinflowAccountsServiceProvider extends ServiceProvider $this->app->singleton(BitinflowAccounts::class, function () { return new BitinflowAccounts; }); + + $this->registerGuard(); } /** @@ -43,4 +50,37 @@ class BitinflowAccountsServiceProvider extends ServiceProvider { return [BitinflowAccounts::class]; } + + /** + * Register the token guard. + * + * @return void + */ + protected function registerGuard() + { + Auth::resolved(function ($auth) { + $auth->extend('bitinflow-accounts', function ($app, $name, array $config) { + return tap($this->makeGuard($config), function ($guard) { + $this->app->refresh('request', $guard, 'setRequest'); + }); + }); + }); + } + + /** + * Make an instance of the token guard. + * + * @param array $config + * @return RequestGuard + */ + protected function makeGuard(array $config): RequestGuard + { + return new RequestGuard(function ($request) use ($config) { + return (new TokenGuard( + new UserProvider(Auth::createUserProvider($config['provider']), $config['provider']), + $this->app->make('encrypter'), + $this->app->make(JwtParser::class) + ))->user($request); + }, $this->app['request']); + } } diff --git a/src/GhostZero/BitinflowAccounts/Traits/HasBitinflowTokens.php b/src/GhostZero/BitinflowAccounts/Traits/HasBitinflowTokens.php new file mode 100644 index 0000000..76a40b1 --- /dev/null +++ b/src/GhostZero/BitinflowAccounts/Traits/HasBitinflowTokens.php @@ -0,0 +1,50 @@ +accessToken; + } + + /** + * Determine if the current API token has a given scope. + * + * @param string $scopeUserProvider + * @return bool + */ + public function bitinflowTokenCan(string $scope): bool + { + return $this->accessToken ? in_array($scope, $this->accessToken->scopes) : false; + } + + /** + * Set the current access token for the user. + * + * @param stdClass $accessToken + * @return $this + */ + public function withBitinflowAccessToken(stdClass $accessToken): self + { + $this->accessToken = $accessToken; + + return $this; + } +} \ No newline at end of file