Skip to content

Commit

Permalink
Add hasAnyRole function to validate multiple roles for a given resour…
Browse files Browse the repository at this point in the history
…ce (#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
  • Loading branch information
luizjr authored Apr 25, 2023
1 parent ade40e3 commit ccbc9b7
Show file tree
Hide file tree
Showing 3 changed files with 149 additions and 50 deletions.
103 changes: 54 additions & 49 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<p align="center">
<img src="bird.png">
<img src="bird.png">
</p>
<p align="center">
&nbsp;
Expand All @@ -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

Expand All @@ -28,23 +27,22 @@ 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

<p align="center">
<img src="flow.png">
<img src="flow.png">
</p>


1. The frontend user authenticates on Keycloak Server

1. The frontend user obtains a JWT token.

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.
Expand All @@ -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
Expand All @@ -90,76 +88,73 @@ 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.

It fetchs user from database and fill values into authenticated user object. If enabled, it will work together with `user_provider_credential` and `token_principal_attribute`.

✔️ **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 <DATE>"* 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 <DATE>"_ 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.

Expand All @@ -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' => [
# <!-----
# <!-----
# Make sure your "api" guard looks like this.
# Newer Laravel versions just removed this config block.
# ---->
'api' => [
'driver' => 'keycloak',
# ---->
'api' => [
'driver' => 'keycloak',
'provider' => 'users',
],
],
```

## Laravel Routes

Just protect some endpoints on `routes/api.php` and you are done!

```php
Expand All @@ -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
Expand Down Expand Up @@ -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()
```

<br>

`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
Expand All @@ -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).
Expand All @@ -291,7 +297,6 @@ composer test
composer test:coverage
```


# Contact

Twitter [@robsontenorio](https://twitter.com/robsontenorio)
25 changes: 25 additions & 0 deletions src/KeycloakGuard.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
71 changes: 70 additions & 1 deletion tests/AuthenticateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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);

Expand Down

0 comments on commit ccbc9b7

Please sign in to comment.