From ccbc9b75e2ab776aa13b15ad2da3d4a53a84cf19 Mon Sep 17 00:00:00 2001 From: Luiz Junior Date: Mon, 24 Apr 2023 22:20:09 -0300 Subject: [PATCH] Add hasAnyRole function to validate multiple roles for a given resource (#88) * Add hasAnyRole function to validate multiple roles for a given resource In this commit, a new function called hasAnyRole was added to check if a user possesses any of the specified roles for a given resource. This function accepts a resource and an array of roles as input parameters and returns true if the user has at least one of the roles, or false otherwise. The implementation builds on the existing hasRole function, iterating through the array of roles to perform the necessary validation. * Update AuthenticateTest.php * Att readme.md include hasAnyRole --- README.md | 103 +++++++++++++++++++------------------ src/KeycloakGuard.php | 25 +++++++++ tests/AuthenticateTest.php | 71 ++++++++++++++++++++++++- 3 files changed, 149 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 414a0ac..3b64e31 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

  @@ -11,8 +11,7 @@ # Simple Keycloak Guard for Laravel / Lumen -This package helps you authenticate users on a Laravel API based on JWT tokens generated from **Keycloak Server**. - +This package helps you authenticate users on a Laravel API based on JWT tokens generated from **Keycloak Server**. # Requirements @@ -28,16 +27,14 @@ This package helps you authenticate users on a Laravel API based on JWT tokens g ✔️ The frontend make requests to the Laravel API, with that token. - 💔 If your app does not match requirements, probably you are looking for https://socialiteproviders.com/Keycloak or https://github.com/Vizir/laravel-keycloak-web-guard # The flow

- +

- 1. The frontend user authenticates on Keycloak Server 1. The frontend user obtains a JWT token. @@ -45,6 +42,7 @@ This package helps you authenticate users on a Laravel API based on JWT tokens g 1. In another moment, the frontend user makes a request to some protected endpoint on a Laravel API, with that token. 1. The Laravel API (through `Keycloak Guard`) handle it. + - Verify token signature. - Verify token structure. - Verify token expiration time. @@ -68,20 +66,20 @@ composer require robsontenorio/laravel-keycloak-guard ### Lumen only -Register the provider in your boostrap app file ```bootstrap/app.php``` +Register the provider in your boostrap app file `bootstrap/app.php` -Add the following line in the "Register Service Providers" section at the bottom of the file. +Add the following line in the "Register Service Providers" section at the bottom of the file. ```php $app->register(\KeycloakGuard\KeycloakGuardServiceProvider::class); ``` -For facades, uncomment ```$app->withFacades();``` in your boostrap app file ```bootstrap/app.php``` + +For facades, uncomment `$app->withFacades();` in your boostrap app file `bootstrap/app.php` # Configuration ## Keycloak Guard - ⚠️ When editing `.env` make sure all strings **are trimmed.** ```bash @@ -90,19 +88,17 @@ For facades, uncomment ```$app->withFacades();``` in your boostrap app file ```b php artisan vendor:publish --provider="KeycloakGuard\KeycloakGuardServiceProvider" ``` +✔️ **realm_public_key** -✔️ **realm_public_key** - -*Required.* +_Required._ The Keycloak Server realm public key (string). -> How to get realm public key? Click on "Realm Settings" > "Keys" > "Algorithm RS256" Line > "Public Key" Button - +> How to get realm public key? Click on "Realm Settings" > "Keys" > "Algorithm RS256" Line > "Public Key" Button ✔️ **load_user_from_database** -*Required. Default is `true`.* +_Required. Default is `true`._ If you do not have an `users` table you must disable this. @@ -110,56 +106,55 @@ It fetchs user from database and fill values into authenticated user object. If ✔️ **user_provider_custom_retrieve_method** -*Default is `null`.* +_Default is `null`._ -If you have an `users` table and want it to be updated (creating or updating users) based on the token, you can inform a custom method on a custom UserProvider, that will be called instead `retrieveByCredentials` and will receive the complete decoded token as parameter, not just the credentials (as default). +If you have an `users` table and want it to be updated (creating or updating users) based on the token, you can inform a custom method on a custom UserProvider, that will be called instead `retrieveByCredentials` and will receive the complete decoded token as parameter, not just the credentials (as default). This will allow you to customize the way you want to interact with your database, before matching and delivering the authenticated user object, having all the information contained in the (valid) access token available. To read more about custom UserProviders, please check [Laravel's documentation about](https://laravel.com/docs/8.x/authentication#adding-custom-user-providers). If using this feature, obviously, values defined for `user_provider_credential` and `token_principal_attribute` will be ignored. ✔️ **user_provider_credential** -*Required. -Default is `username`.* - +_Required. +Default is `username`._ -The field from `users` table that contains the user unique identifier (eg. username, email, nickname). This will be confronted against `token_principal_attribute` attribute, while authenticating. +The field from "users" table that contains the user unique identifier (eg. username, email, nickname). This will be confronted against `token_principal_attribute` attribute, while authenticating. ✔️ **token_principal_attribute** -*Required. -Default is `preferred_username`.* +_Required. +Default is `preferred_username`._ The property from JWT token that contains the user identifier. -This will be confronted against `user_provider_credential` attribute, while authenticating. +This will be confronted against `user_provider_credential` attribute, while authenticating. ✔️ **append_decoded_token** -*Default is `false`.* +_Default is `false`._ Appends to the authenticated user the full decoded JWT token (`$user->token`). Useful if you need to know roles, groups and other user info holded by JWT token. Even choosing `false`, you can also get it using `Auth::token()`, see API section. ✔️ **allowed_resources** -*Required*. +_Required_. -Usually you API should handle one *resource_access*. But, if you handle multiples, just use a comma separated list of allowed resources accepted by API. This attribute will be confronted against `resource_access` attribute from JWT token, while authenticating. +Usually you API should handle one _resource_access_. But, if you handle multiples, just use a comma separated list of allowed resources accepted by API. This attribute will be confronted against `resource_access` attribute from JWT token, while authenticating. ✔️ **ignore_resources_validation** -*Default is `false`*. +_Default is `false`_. -Disables entirely resources validation. It will **ignore** *allowed_resources* configuration. +Disables entirely resources validation. It will **ignore** _allowed_resources_ configuration. ✔️ **leeway** -*Default is `0`*. - - You can add a leeway to account for when there is a clock skew times between the signing and verifying servers. If you are facing issues like *"Cannot handle token prior to "* try to set it `60` (seconds). +_Default is `0`_. + +You can add a leeway to account for when there is a clock skew times between the signing and verifying servers. If you are facing issues like _"Cannot handle token prior to "_ try to set it `60` (seconds). - ✔️ **input_key** +✔️ **input_key** -*Default is `null`.* +_Default is `null`._ By default this package **always** will look at first for a `Bearer` token. Additionally, if this option is enabled, then it will try to get a token from this custom request param. @@ -172,32 +167,33 @@ GET $this->get("/foo/secret?api_token=xxxxx") POST $this->post("/foo/secret", ["api_token" => "xxxxx"]) ``` - ## Laravel Auth Changes on `config/auth.php` + ```php ... 'defaults' => [ 'guard' => 'api', # <-- For sure, i`m building an API 'passwords' => 'users', ], - + .... - + 'guards' => [ - # - 'api' => [ - 'driver' => 'keycloak', + # ----> + 'api' => [ + 'driver' => 'keycloak', 'provider' => 'users', ], ], ``` ## Laravel Routes + Just protect some endpoints on `routes/api.php` and you are done! ```php @@ -213,8 +209,8 @@ Route::group(['middleware' => 'auth:api'], function () { }); ``` - ## Lumen Routes + Just protect some endpoints on `routes/web.php` and you are done! ```php @@ -243,19 +239,19 @@ Simple Keycloak Guard implements `Illuminate\Contracts\Auth\Guard`. So, all Lara - `validate()` - `setUser()` - ## Keycloak Guard methods -`token()` -*Returns full decoded JWT token from authenticated user.* +`token()` +_Returns full decoded JWT token from authenticated user._ ```php $token = Auth::token() // or Auth::user()->token() ``` +
-`hasRole('some-resource', 'some-role')` -*Check if authenticated user has a role on resource_access* +`hasRole('some-resource', 'some-role')` +_Check if authenticated user has a role on resource_access_ ```php // Example decoded payload @@ -275,12 +271,22 @@ $token = Auth::token() // or Auth::user()->token() ] ] ``` + ```php Auth::hasRole('myapp-backend', 'myapp-backend-role1') // true Auth::hasRole('myapp-frontend', 'myapp-frontend-role1') // true Auth::hasRole('myapp-backend', 'myapp-frontend-role1') // false ``` +`hasAnyRole('some-resource', ['some-role1', 'some-role2'])` +_Check if the authenticated user has any of the roles in resource_access_ + +```php +Auth::hasAnyRole('myapp-backend', ['myapp-backend-role1', 'myapp-backend-role3']) // true +Auth::hasAnyRole('myapp-frontend', ['myapp-frontend-role1', 'myapp-frontend-role3']) // true +Auth::hasAnyRole('myapp-backend', ['myapp-frontend-role1', 'myapp-frontend-role2']) // false +``` + # Contribute You can run this project on VSCODE with Remote Container. Make sure you will use internal VSCODE terminal (inside running container). @@ -291,7 +297,6 @@ composer test composer test:coverage ``` - # Contact Twitter [@robsontenorio](https://twitter.com/robsontenorio) diff --git a/src/KeycloakGuard.php b/src/KeycloakGuard.php index 42791a2..6d30208 100644 --- a/src/KeycloakGuard.php +++ b/src/KeycloakGuard.php @@ -214,4 +214,29 @@ public function hasRole($resource, $role) return false; } + + /** + * Check if authenticated user has a any role into resource + * @param string $resource + * @param string $role + * @return bool + */ + public function hasAnyRole($resource, array $roles) + { + $token_resource_access = (array)$this->decodedToken->resource_access; + + if (array_key_exists($resource, $token_resource_access)) { + $token_resource_values = (array)$token_resource_access[$resource]; + + if (array_key_exists('roles', $token_resource_values)) { + foreach ($roles as $role) { + if (in_array($role, $token_resource_values['roles'])) { + return true; + } + } + } + } + + return false; + } } diff --git a/tests/AuthenticateTest.php b/tests/AuthenticateTest.php index a3a64b5..0ab2996 100644 --- a/tests/AuthenticateTest.php +++ b/tests/AuthenticateTest.php @@ -210,6 +210,75 @@ public function test_prevent_cross_roles_resources() $this->assertFalse(Auth::hasRole('myapp-backend', 'myapp-frontend-role1')); } + public function test_check_user_has_any_role_in_resource() + { + $this->buildCustomToken([ + 'resource_access' => [ + 'myapp-backend' => [ + 'roles' => [ + 'myapp-backend-role1', + 'myapp-backend-role2' + ] + ], + 'myapp-frontend' => [ + 'roles' => [ + 'myapp-frontend-role1', + 'myapp-frontend-role2' + ] + ] + ] + ]); + + $this->withKeycloakToken()->json('GET', '/foo/secret'); + $this->assertTrue(Auth::hasAnyRole('myapp-backend', ['myapp-backend-role1', 'myapp-backend-role3'])); + } + + public function test_check_user_no_has_any_role_in_resource() + { + $this->buildCustomToken([ + 'resource_access' => [ + 'myapp-backend' => [ + 'roles' => [ + 'myapp-backend-role1', + 'myapp-backend-role2' + ] + ], + 'myapp-frontend' => [ + 'roles' => [ + 'myapp-frontend-role1', + 'myapp-frontend-role2' + ] + ] + ] + ]); + + $this->withKeycloakToken()->json('GET', '/foo/secret'); + $this->assertFalse(Auth::hasAnyRole('myapp-backend', ['myapp-backend-role3', 'myapp-backend-role4'])); + } + + public function test_prevent_cross_roles_resources_with_any_role() + { + $this->buildCustomToken([ + 'resource_access' => [ + 'myapp-backend' => [ + 'roles' => [ + 'myapp-backend-role1', + 'myapp-backend-role2' + ] + ], + 'myapp-frontend' => [ + 'roles' => [ + 'myapp-frontend-role1', + 'myapp-frontend-role2' + ] + ] + ] + ]); + + $this->withKeycloakToken()->json('GET', '/foo/secret'); + $this->assertFalse(Auth::hasAnyRole('myapp-backend', ['myapp-frontend-role1', 'myapp-frontend-role2'])); + } + public function test_custom_user_retrieve_method() { config(['keycloak.user_provider_custom_retrieve_method' => 'custom_retrieve']); @@ -254,7 +323,7 @@ public function test_authenticates_with_custom_input_key() { config(['keycloak.input_key' => "api_token"]); - $this->json('GET', '/foo/secret?api_token='.$this->token); + $this->json('GET', '/foo/secret?api_token=' . $this->token); $this->assertEquals(Auth::id(), $this->user->id);