Add jwt handling, inspired by passport

This commit is contained in:
René Preuß
2021-03-30 23:19:36 +02:00
parent 32990da8a0
commit 14bf9d5480
14 changed files with 872 additions and 56 deletions

View File

@@ -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",

View File

@@ -0,0 +1,82 @@
<?php
namespace GhostZero\BitinflowAccounts;
use Carbon\Carbon;
use Firebase\JWT\JWT;
use Illuminate\Contracts\Config\Repository as Config;
use Illuminate\Contracts\Encryption\Encrypter;
use Symfony\Component\HttpFoundation\Cookie;
class ApiTokenCookieFactory
{
/**
* The configuration repository implementation.
*
* @var Config
*/
protected $config;
/**
* The encrypter implementation.
*
* @var Encrypter
*/
protected $encrypter;
/**
* Create an API token cookie factory instance.
*
* @param Config $config
* @param Encrypter $encrypter
* @return void
*/
public function __construct(Config $config, Encrypter $encrypter)
{
$this->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());
}
}

View File

@@ -0,0 +1,229 @@
<?php
namespace GhostZero\BitinflowAccounts\Auth;
use Exception;
use Firebase\JWT\JWT;
use GhostZero\BitinflowAccounts\BitinflowAccounts;
use GhostZero\BitinflowAccounts\Helpers\JwtParser;
use GhostZero\BitinflowAccounts\Traits\HasBitinflowTokens;
use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Container\Container;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Cookie\CookieValuePrefix;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Http\Request;
use stdClass;
use Throwable;
class TokenGuard
{
use GuardHelpers;
/**
* @var Encrypter
*/
private $encrypter;
/**
* @var JwtParser
*/
private $jwtParser;
public function __construct(UserProvider $provider, Encrypter $encrypter, JwtParser $jwtParser)
{
$this->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');
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace GhostZero\BitinflowAccounts\Auth;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider as Base;
class UserProvider implements Base
{
/**
* The user provider instance.
*
* @var Base
*/
protected $provider;
/**
* The user provider name.
*
* @var string
*/
protected $providerName;
/**
* Create a new Bitinflow Accounts user provider.
*
* @param Base $provider
* @param string $providerName
* @return void
*/
public function __construct(Base $provider, $providerName)
{
$this->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;
}
}

View File

@@ -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
*

View File

@@ -0,0 +1,41 @@
<?php
namespace GhostZero\BitinflowAccounts\Helpers;
use Firebase\JWT\JWT;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
use stdClass;
use Throwable;
class JwtParser
{
public const ALLOWED_ALGORITHMS = ['RS256'];
/**
* @param Request $request
* @return stdClass
* @throws AuthenticationException
*/
public function decode(Request $request): stdClass
{
JWT::$leeway = 60;
try {
return JWT::decode(
$request->bearerToken(),
$this->getOauthPublicKey(),
self::ALLOWED_ALGORITHMS
);
} catch (Throwable $exception) {
throw (new AuthenticationException());
}
}
private function getOauthPublicKey()
{
return file_get_contents(__DIR__ . '/../../../../oauth-public.key');
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace GhostZero\BitinflowAccounts\Http\Middleware;
use GhostZero\BitinflowAccounts\Exceptions\MissingScopeException;
use stdClass;
class CheckClientCredentialsForAnyScope extends CheckCredentials
{
/**
* Validate token credentials.
*
* @param stdClass $token
* @param array $scopes
*
* @return void
* @throws MissingScopeException
*
*/
protected function validateScopes(stdClass $token, array $scopes)
{
if (in_array('*', $token->scopes)) {
return;
}
foreach ($scopes as $scope) {
if (in_array($scope, $token->scopes)) {
return;
}
}
throw new MissingScopeException($scopes);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace GhostZero\BitinflowAccounts\Http\Middleware;
use Closure;
use GhostZero\BitinflowAccounts\Exceptions\MissingScopeException;
use GhostZero\BitinflowAccounts\Helpers\JwtParser;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
use stdClass;
abstract class CheckCredentials
{
/**
* Handle an incoming request.
*
* @param Request $request
* @param Closure $next
* @param mixed ...$scopes
*
* @return mixed
* @throws AuthenticationException|MissingScopeException
*
*/
public function handle($request, Closure $next, ...$scopes)
{
$decoded = $this->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);
}

View File

@@ -0,0 +1,39 @@
<?php
namespace GhostZero\BitinflowAccounts\Http\Middleware;
use Closure;
use GhostZero\BitinflowAccounts\Exceptions\MissingScopeException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class CheckForAnyScope
{
/**
* Handle the incoming request.
*
* @param Request $request
* @param Closure $next
* @param mixed ...$scopes
* @return Response
*
* @throws AuthenticationException|MissingScopeException
*/
public function handle($request, $next, ...$scopes)
{
if (! $request->user() || ! $request->user()->bitinflowToken()) {
throw new AuthenticationException;
}
foreach ($scopes as $scope) {
if ($request->user()->bitinflowTokenCan($scope)) {
return $next($request);
}
}
throw new MissingScopeException($scopes);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace GhostZero\BitinflowAccounts\Http\Middleware;
use Closure;
use GhostZero\BitinflowAccounts\Exceptions\MissingScopeException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class CheckScopes
{
/**
* Handle the incoming request.
*
* @param Request $request
* @param Closure $next
* @param mixed ...$scopes
* @return Response
*
* @throws AuthenticationException|MissingScopeException
*/
public function handle($request, $next, ...$scopes)
{
if (! $request->user() || ! $request->user()->bitinflowToken()) {
throw new AuthenticationException;
}
foreach ($scopes as $scope) {
if (! $request->user()->bitinflowTokenCan($scope)) {
throw new MissingScopeException($scope);
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace GhostZero\BitinflowAccounts\Http\Middleware;
use Closure;
use GhostZero\BitinflowAccounts\ApiTokenCookieFactory;
use GhostZero\BitinflowAccounts\BitinflowAccounts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class CreateFreshApiToken
{
/**
* The API token cookie factory instance.
*
* @var ApiTokenCookieFactory
*/
protected $cookieFactory;
/**
* The authentication guard.
*
* @var string
*/
protected $guard;
/**
* Create a new middleware instance.
*
* @param ApiTokenCookieFactory $cookieFactory
* @return void
*/
public function __construct(ApiTokenCookieFactory $cookieFactory)
{
$this->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;
}
}

View File

@@ -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']);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace GhostZero\BitinflowAccounts\Traits;
use Illuminate\Container\Container;
use stdClass;
trait HasBitinflowTokens
{
/**
* The current access token for the authentication user.
*
* @var stdClass
*/
protected $accessToken;
/**
* Get the current access token being used by the user.
*
* @return stdClass|null
*/
public function bitinflowToken(): ?stdClass
{
return $this->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;
}
}