From 979fc2edfd9fabb33100439916903970d78e9ab6 Mon Sep 17 00:00:00 2001 From: Hergen Dillema Date: Tue, 15 Sep 2020 13:24:42 +0200 Subject: [PATCH] 0.2.0 - Removed support for spatie's permissions - Refactored class names - Added config options - Allows use without depending on database --- composer.json | 2 +- config/jwtAuthRoles.php | 39 ++-- config/jwtauthroles.php | 34 ++++ .../migrations/create_jwtauth_tables.php.stub | 3 +- readme.md | 1 - src/Exceptions/AuthException.php | 15 ++ src/Exceptions/authException.php | 2 +- src/Facades/jwtAuthRoles.php | 18 -- src/JwtAuthRoles.php | 168 ++++++++++++++++++ src/JwtAuthRolesServiceProvider.php | 106 +++++++++++ src/Models/JwtUser.php | 6 +- src/jwtAuthRoles.php | 83 ++++----- src/jwtAuthRolesServiceProvider.php | 18 +- 13 files changed, 399 insertions(+), 96 deletions(-) create mode 100644 config/jwtauthroles.php create mode 100644 src/Exceptions/AuthException.php delete mode 100644 src/Facades/jwtAuthRoles.php create mode 100644 src/JwtAuthRoles.php create mode 100644 src/JwtAuthRolesServiceProvider.php diff --git a/composer.json b/composer.json index ae936f2..04de88f 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "werk365/jwtauthroles", "description": "Made to use fusionauth users in Laravel using JWT. Possible to either use pem keys directly or use the jwks endpoint.", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "authors": [ { diff --git a/config/jwtAuthRoles.php b/config/jwtAuthRoles.php index 647109d..2f6d089 100644 --- a/config/jwtAuthRoles.php +++ b/config/jwtAuthRoles.php @@ -1,31 +1,34 @@ env('JWKS_URL', 'http://localhost:9011/.well-known/jwks.json'), - 'pemUri' => env('PEM_URL', 'http://localhost:9011/api/jwt/public-key'), - - // Configure to use PEM endpoint (default) or JWK - 'useJwk' => env('USE_JWK', true), + // If enabled, stores every user in the database + 'useDB' => env('FA_USE_DB', false), - // Column name in the users table where uuid should be stored. Defaults to id but can be another column like 'uuid' - 'userId' => env('FA_USR_ID', 'id'), - - 'autoCreateUser' => env('FA_CREATE_USR', true), - - // This uses spatie's permissions package to give users the fusionauth roles. - // To use this, installing the permissions package is required - 'usePermissions' => env('FA_USE_PERM', true), - // Creates a role if not found in database - 'autoCreateRoles' => env('FA_CREATE_ROLES', true), + // Only if useDB = true + // Column name in the users table where uuid should be stored.' + 'userId' => env('FA_USR_ID', 'uuid'), + // Only if useDB = true + 'autoCreateUser' => env('FA_CREATE_USR', false), 'alg' => env('FA_ALG', 'RS256'), + // Allows you to skip validation, this is potentially dangerous, + // only use for testing or if the jwt has been validated by something like an api gateway + 'validateJwt' => env('FA_VALIDATE', true), + + // Only if validateJwt = true 'cache' => [ 'enabled' => env('FA_CACHE_ENABLED', false), 'type' => env('FA_CACHE_TYPE', 'database'), ], - // Allows you to skip validation, this is potentially dangerous, - // only use for testing or if the jwt has been validated by something like an api gateway - 'validateJwt' => env('FA_VALIDATE', false), + // Only if validateJwt = true + 'jwkUri' => env('JWKS_URL', 'http://localhost:9011/.well-known/jwks.json'), + // Only if validateJwt = true + 'pemUri' => env('PEM_URL', 'http://localhost:9011/api/jwt/public-key'), + + // Only if validateJwt = true + // Configure to use PEM endpoint (default) or JWK + 'useJwk' => env('USE_JWK', false), + ]; diff --git a/config/jwtauthroles.php b/config/jwtauthroles.php new file mode 100644 index 0000000..2f6d089 --- /dev/null +++ b/config/jwtauthroles.php @@ -0,0 +1,34 @@ + env('FA_USE_DB', false), + + // Only if useDB = true + // Column name in the users table where uuid should be stored.' + 'userId' => env('FA_USR_ID', 'uuid'), + // Only if useDB = true + 'autoCreateUser' => env('FA_CREATE_USR', false), + + 'alg' => env('FA_ALG', 'RS256'), + + // Allows you to skip validation, this is potentially dangerous, + // only use for testing or if the jwt has been validated by something like an api gateway + 'validateJwt' => env('FA_VALIDATE', true), + + // Only if validateJwt = true + 'cache' => [ + 'enabled' => env('FA_CACHE_ENABLED', false), + 'type' => env('FA_CACHE_TYPE', 'database'), + ], + + // Only if validateJwt = true + 'jwkUri' => env('JWKS_URL', 'http://localhost:9011/.well-known/jwks.json'), + // Only if validateJwt = true + 'pemUri' => env('PEM_URL', 'http://localhost:9011/api/jwt/public-key'), + + // Only if validateJwt = true + // Configure to use PEM endpoint (default) or JWK + 'useJwk' => env('USE_JWK', false), + +]; diff --git a/database/migrations/create_jwtauth_tables.php.stub b/database/migrations/create_jwtauth_tables.php.stub index b72945b..d5d2109 100644 --- a/database/migrations/create_jwtauth_tables.php.stub +++ b/database/migrations/create_jwtauth_tables.php.stub @@ -23,7 +23,8 @@ class CreateJwtAuthTables extends Migration Schema::create('jwt_users', function (Blueprint $table) { $table->id(); $table->uuid('uuid'); - $table->text('jwt'); + $table->json('roles'); + $table->json('claims'); $table->timestamps(); }); } diff --git a/readme.md b/readme.md index 632591a..379fb9c 100644 --- a/readme.md +++ b/readme.md @@ -11,7 +11,6 @@ Made to use fusionauth users in laravel using JWT. Possible to either use pem keys directly or use the jwks endpoint. -User roles from JWT can be converted to spatie/laravel-permissions roles automatically so the package can be used normally for permissions. Even though it was made for fusionauth, should be quite general purpose for using JWTs/ jwks and roles in laravel. diff --git a/src/Exceptions/AuthException.php b/src/Exceptions/AuthException.php new file mode 100644 index 0000000..41d23f7 --- /dev/null +++ b/src/Exceptions/AuthException.php @@ -0,0 +1,15 @@ +alg) && $header->alg !== config('jwtauthroles.alg')) { + throw AuthException::auth(422, 'Invalid algorithm'); + } + + return $header->kid ?? null; + } else { + throw AuthException::auth(422, 'Malformed JWT'); + } + } + + private static function getClaims(string $jwt): ?object + { + if (Str::is('*.*.*', $jwt)) { + $claims = explode('.', $jwt); + $claims = JWT::jsonDecode(JWT::urlsafeB64Decode($claims[1])); + + return $claims ?? null; + } else { + throw AuthException::auth(422, 'Malformed JWT'); + } + } + + /** + * @param object $jwk + * @return bool|string|null + */ + private static function jwkToPem(object $jwk) + { + if (isset($jwk->e) && isset($jwk->n)) { + $rsa = new RSA(); + $rsa->loadKey([ + 'e' => new BigInteger(JWT::urlsafeB64Decode($jwk->e), 256), + 'n' => new BigInteger(JWT::urlsafeB64Decode($jwk->n), 256), + ]); + + return $rsa->getPublicKey(); + } + throw AuthException::auth(500, 'Malformed jwk'); + } + + /** + * @param string $kid + * @param string $uri + * @return bool|string|null + */ + private static function getJwk(string $kid, string $uri) + { + $response = Http::get($uri); + $json = $response->getBody(); + if ($json) { + $jwks = json_decode($json, false); + if ($jwks && isset($jwks->keys) && is_array($jwks->keys)) { + foreach ($jwks->keys as $jwk) { + if ($jwk->kid === $kid) { + return self::jwkToPem($jwk); + } + } + } + } + throw AuthException::auth(404, 'jwks endpoint not found'); + } + + private static function getPem(string $kid, string $uri): ?string + { + $response = Http::get($uri); + $json = $response->getBody(); + if ($json) { + $pems = json_decode($json, false); + if ($pems && isset($pems->publicKeys) && is_object($pems->publicKeys)) { + foreach ($pems->publicKeys as $key=>$pem) { + if ($key === $kid) { + return $pem; + } + } + } + } + throw AuthException::auth(404, 'pem endpoint not found'); + } + + private static function verifyToken(string $jwt, string $uri, bool $jwk = false): object + { + $kid = self::getKid($jwt); + if (! $kid) { + throw AuthException::auth(422, 'Malformed JWT'); + } + if (config('jwtauthroles.cache.enabled')) { + if (config('jwtauthroles.cache.type') === 'database') { + $row = JwtKey::where('kid', $kid) + ->orderBy('created_at', 'desc') + ->first('key'); + } + } + + $publicKey = $row->key + ?? $jwk + ? self::getJwk($kid, $uri) + : self::getPem($kid, $uri); + + if (! isset($publicKey) || ! $publicKey) { + throw AuthException::auth(500, 'Unable to validate JWT'); + } + + if (config('jwtauthroles.cache.enabled')) { + if (config('jwtauthroles.cache.type') === 'database') { + $row = $row ?? JwtKey::create(['kid' => $kid, 'key' => $publicKey]); + } + } + + return JWT::decode($jwt, $publicKey, [config('jwtauthroles.alg')]); + } + + /** @return mixed */ + public static function authUser(object $request) + { + $jwt = $request->bearerToken(); + + $uri = config('jwtauthroles.useJwk') + ? config('jwtauthroles.jwkUri') + : config('jwtauthroles.pemUri'); + + if (! config('jwtauthroles.validateJwt')) { + $claims = self::getClaims($jwt); + } else { + $claims = self::verifyToken($jwt, $uri, config('jwtauthroles.useJwk')); + } + + if(config('jwtauthroles.useDB')) { + if (config('jwtauthroles.autoCreateUser')) { + $user = JwtUser::firstOrNew([config('jwtauthroles.userId') => $claims->sub]); + $user[config('jwtauthroles.userId')] = $claims->sub; + $user->roles = json_encode($claims->roles); + $user->claims = json_encode($claims); + $user->save(); + } else { + $user = JwtUser::where(config('jwtauthroles.userId'), '=', $claims->sub)->firstOrFail(); + $user->roles = json_encode($claims->roles); + $user->claims = json_encode($claims); + $user->save(); + } + } else { + $user = new JwtUser(); + $user->uuid = $claims->sub; + $user->roles = $claims->roles; + $user->claims = $claims; + } + + return $user; + } +} diff --git a/src/JwtAuthRolesServiceProvider.php b/src/JwtAuthRolesServiceProvider.php new file mode 100644 index 0000000..0ac52b7 --- /dev/null +++ b/src/JwtAuthRolesServiceProvider.php @@ -0,0 +1,106 @@ +loadTranslationsFrom(__DIR__.'/../resources/lang', 'werk365'); + // $this->loadViewsFrom(__DIR__.'/../resources/views', 'werk365'); + // $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + // $this->loadRoutesFrom(__DIR__.'/routes.php'); + + // Publishing is only necessary when using the CLI. + if ($this->app->runningInConsole()) { + $this->bootForConsole(); + } + + if (function_exists('config_path')) { // function not available and 'publish' not relevant in Lumen + $this->publishes([ + __DIR__ . '/../config/jwtauthroles.php' => config_path('jwtauthroles.php'), + ], 'config'); + + $this->publishes([ + __DIR__.'/../database/migrations/create_jwtauth_tables.php.stub' => $this->getMigrationFileName($filesystem), + ], 'migrations'); + } + } + + /** + * Register any package services. + * + * @return void + */ + public function register() + { + $this->mergeConfigFrom(__DIR__ . '/../config/jwtauthroles.php', 'jwtauthroles'); + + // Register the service the package provides. + $this->app->singleton('JwtAuthRoles', function ($app) { + return new JwtAuthRoles; + }); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return ['JwtAuthRoles']; + } + + /** + * Console-specific booting. + * + * @return void + */ + protected function bootForConsole() + { + // Publishing the configuration file. + $this->publishes([ + __DIR__ . '/../config/jwtauthroles.php' => config_path('jwtauthroles.php'), + ], 'jwtauthroles.config'); + + // Publishing the views. + /*$this->publishes([ + __DIR__.'/../resources/views' => base_path('resources/views/vendor/werk365'), + ], 'jwtAuthRoles.views');*/ + + // Publishing assets. + /*$this->publishes([ + __DIR__.'/../resources/assets' => public_path('vendor/werk365'), + ], 'jwtAuthRoles.views');*/ + + // Publishing the translation files. + /*$this->publishes([ + __DIR__.'/../resources/lang' => resource_path('lang/vendor/werk365'), + ], 'jwtAuthRoles.views');*/ + + // Registering package commands. + // $this->commands([]); + } + + /** + * Returns existing migration file if found, else uses the current timestamp. + * + * @param Filesystem $filesystem + * @return string + */ + protected function getMigrationFileName(Filesystem $filesystem): string + { + $timestamp = date('Y_m_d_His'); + + return Collection::make($this->app->databasePath().DIRECTORY_SEPARATOR.'migrations'.DIRECTORY_SEPARATOR) + ->flatMap(function ($path) use ($filesystem) { + return $filesystem->glob($path.'*_create_jwtauth_tables.php'); + })->push($this->app->databasePath()."/migrations/{$timestamp}_create_jwtauth_tables.php") + ->first(); + } +} diff --git a/src/Models/JwtUser.php b/src/Models/JwtUser.php index 604cf3a..6496c78 100644 --- a/src/Models/JwtUser.php +++ b/src/Models/JwtUser.php @@ -2,13 +2,13 @@ namespace werk365\jwtauthroles\Models; +use App\User; use Illuminate\Foundation\Auth\User as Authenticatable; use Spatie\Permission\Traits\HasRoles; -class JwtUser extends Authenticatable +class JwtUser extends User { - // use HasRoles; protected $guard_name = 'jwt'; - protected $fillable = ['uuid', 'jwt']; + protected $fillable = ['uuid', 'roles', 'claims']; } diff --git a/src/jwtAuthRoles.php b/src/jwtAuthRoles.php index cb89b05..dc33755 100644 --- a/src/jwtAuthRoles.php +++ b/src/jwtAuthRoles.php @@ -1,30 +1,29 @@ alg) && $header->alg !== config('jwtAuthRoles.alg')) { - throw authException::auth(422, 'Invalid algorithm'); + if (isset($header->alg) && $header->alg !== config('jwtauthroles.alg')) { + throw AuthException::auth(422, 'Invalid algorithm'); } return $header->kid ?? null; } else { - throw authException::auth(422, 'Malformed JWT'); + throw AuthException::auth(422, 'Malformed JWT'); } } @@ -36,7 +35,7 @@ private static function getClaims(string $jwt): ?object return $claims ?? null; } else { - throw authException::auth(422, 'Malformed JWT'); + throw AuthException::auth(422, 'Malformed JWT'); } } @@ -55,7 +54,7 @@ private static function jwkToPem(object $jwk) return $rsa->getPublicKey(); } - throw authException::auth(500, 'Malformed jwk'); + throw AuthException::auth(500, 'Malformed jwk'); } /** @@ -77,7 +76,7 @@ private static function getJwk(string $kid, string $uri) } } } - throw authException::auth(404, 'jwks endpoint not found'); + throw AuthException::auth(404, 'jwks endpoint not found'); } private static function getPem(string $kid, string $uri): ?string @@ -94,17 +93,17 @@ private static function getPem(string $kid, string $uri): ?string } } } - throw authException::auth(404, 'pem endpoint not found'); + throw AuthException::auth(404, 'pem endpoint not found'); } private static function verifyToken(string $jwt, string $uri, bool $jwk = false): object { $kid = self::getKid($jwt); if (! $kid) { - throw authException::auth(422, 'Malformed JWT'); + throw AuthException::auth(422, 'Malformed JWT'); } - if (config('jwtAuthRoles.cache.enabled')) { - if (config('jwtAuthRoles.cache.type') === 'database') { + if (config('jwtauthroles.cache.enabled')) { + if (config('jwtauthroles.cache.type') === 'database') { $row = JwtKey::where('kid', $kid) ->orderBy('created_at', 'desc') ->first('key'); @@ -117,16 +116,16 @@ private static function verifyToken(string $jwt, string $uri, bool $jwk = false) : self::getPem($kid, $uri); if (! isset($publicKey) || ! $publicKey) { - throw authException::auth(500, 'Unable to validate JWT'); + throw AuthException::auth(500, 'Unable to validate JWT'); } - if (config('jwtAuthRoles.cache.enabled')) { - if (config('jwtAuthRoles.cache.type') === 'database') { + if (config('jwtauthroles.cache.enabled')) { + if (config('jwtauthroles.cache.type') === 'database') { $row = $row ?? JwtKey::create(['kid' => $kid, 'key' => $publicKey]); } } - return JWT::decode($jwt, $publicKey, [config('jwtAuthRoles.alg')]); + return JWT::decode($jwt, $publicKey, [config('jwtauthroles.alg')]); } /** @return mixed */ @@ -134,38 +133,34 @@ public static function authUser(object $request) { $jwt = $request->bearerToken(); - $uri = config('jwtAuthRoles.useJwk') - ? config('jwtAuthRoles.jwkUri') - : config('jwtAuthRoles.pemUri'); + $uri = config('jwtauthroles.useJwk') + ? config('jwtauthroles.jwkUri') + : config('jwtauthroles.pemUri'); - if (! config('jwtAuthRoles.validateJwt')) { + if (! config('jwtauthroles.validateJwt')) { $claims = self::getClaims($jwt); } else { - $claims = self::verifyToken($jwt, $uri, config('jwtAuthRoles.useJwk')); + $claims = self::verifyToken($jwt, $uri, config('jwtauthroles.useJwk')); } - if (config('jwtAuthRoles.autoCreateUser')) { - $user = JwtUser::firstOrNew([config('jwtAuthRoles.userId') => $claims->sub]); - $user[config('jwtAuthRoles.userId')] = $claims->sub; - $user->jwt = json_encode($claims); - $user->save(); - } else { - $user = JwtUser::where(config('jwtAuthRoles.userId'), '=', $claims->sub)->firstOrFail(); - $user->jwt = json_encode($claims); - $user->save(); - } - - if (config('jwtAuthRoles.usePermissions')) { - if (config('jwtAuthRoles.autoCreateRoles')) { - foreach ($claims->roles as $role) { - $db_role = Role::where('name', $role)->first(); - if (! $db_role) { - Role::create(['name' => $role, 'guard_name' => 'jwt']); - } - } + if(config('jwtauthroles.useDB')) { + if (config('jwtauthroles.autoCreateUser')) { + $user = JwtUser::firstOrNew([config('jwtauthroles.userId') => $claims->sub]); + $user[config('jwtauthroles.userId')] = $claims->sub; + $user->roles = json_encode($claims->roles); + $user->claims = json_encode($claims); + $user->save(); + } else { + $user = JwtUser::where(config('jwtauthroles.userId'), '=', $claims->sub)->firstOrFail(); + $user->roles = json_encode($claims->roles); + $user->claims = json_encode($claims); + $user->save(); } - // Remove previously assigned roles and update from JWT - $user->syncRoles($claims->roles); + } else { + $user = new JwtUser(); + $user->uuid = $claims->sub; + $user->roles = $claims->roles; + $user->claims = $claims; } return $user; diff --git a/src/jwtAuthRolesServiceProvider.php b/src/jwtAuthRolesServiceProvider.php index b2f3bae..0ac52b7 100644 --- a/src/jwtAuthRolesServiceProvider.php +++ b/src/jwtAuthRolesServiceProvider.php @@ -1,12 +1,12 @@ publishes([ - __DIR__.'/../config/jwtAuthRoles.php' => config_path('jwtAuthRoles.php'), + __DIR__ . '/../config/jwtauthroles.php' => config_path('jwtauthroles.php'), ], 'config'); $this->publishes([ @@ -38,11 +38,11 @@ public function boot(Filesystem $filesystem) */ public function register() { - $this->mergeConfigFrom(__DIR__.'/../config/jwtAuthRoles.php', 'jwtAuthRoles'); + $this->mergeConfigFrom(__DIR__ . '/../config/jwtauthroles.php', 'jwtauthroles'); // Register the service the package provides. - $this->app->singleton('jwtAuthRoles', function ($app) { - return new jwtAuthRoles; + $this->app->singleton('JwtAuthRoles', function ($app) { + return new JwtAuthRoles; }); } @@ -53,7 +53,7 @@ public function register() */ public function provides() { - return ['jwtAuthRoles']; + return ['JwtAuthRoles']; } /** @@ -65,8 +65,8 @@ protected function bootForConsole() { // Publishing the configuration file. $this->publishes([ - __DIR__.'/../config/jwtAuthRoles.php' => config_path('jwtAuthRoles.php'), - ], 'jwtAuthRoles.config'); + __DIR__ . '/../config/jwtauthroles.php' => config_path('jwtauthroles.php'), + ], 'jwtauthroles.config'); // Publishing the views. /*$this->publishes([