From 51d1d01e38cb19487f1fd3454372af5962906838 Mon Sep 17 00:00:00 2001 From: dogukanoksuz <me@dogukan.dev> Date: Tue, 10 Sep 2024 12:12:09 +0000 Subject: [PATCH] feat: Keycloak role permission system --- .../Authentication/KeycloakAuthenticator.php | 97 ++++--- app/Models/Oauth2Token.php | 5 + composer.json | 2 + composer.lock | 252 +++++++++++++++++- ...umn_permissions_to_oauth2_tokens_table.php | 28 ++ 5 files changed, 344 insertions(+), 40 deletions(-) create mode 100644 database/migrations/2024_09_10_142633_add_column_permissions_to_oauth2_tokens_table.php diff --git a/app/Classes/Authentication/KeycloakAuthenticator.php b/app/Classes/Authentication/KeycloakAuthenticator.php index 3099e1cf..02fdb135 100644 --- a/app/Classes/Authentication/KeycloakAuthenticator.php +++ b/app/Classes/Authentication/KeycloakAuthenticator.php @@ -4,47 +4,65 @@ use App\Models\Oauth2Token; use App\Models\User; -use GuzzleHttp\Client; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use Keycloak\KeycloakClient; +use Keycloak\User\UserApi; +use Stevenmaguire\OAuth2\Client\Provider\Keycloak as KeycloakProvider; class KeycloakAuthenticator implements AuthenticatorInterface { - public function authenticate($credentials, $request): JsonResponse + private $kcClient; + + private $oauthProvider; + + private $kcUserApi; + + public function __construct() { - $client = new Client([ - 'verify' => false, + $this->kcClient = new KeycloakClient( + env('KEYCLOAK_CLIENT_ID'), + env('KEYCLOAK_CLIENT_SECRET'), + env('KEYCLOAK_REALM'), + env('KEYCLOAK_BASE_URL'), + null, + '' + ); + + $this->kcUserApi = new UserApi($this->kcClient); + + $this->oauthProvider = new KeycloakProvider([ + 'authServerUrl' => env('KEYCLOAK_BASE_URL'), + 'realm' => env('KEYCLOAK_REALM'), + 'clientId' => env('KEYCLOAK_CLIENT_ID'), + 'clientSecret' => env('KEYCLOAK_CLIENT_SECRET'), + 'redirectUri' => env('KEYCLOAK_REDIRECT_URI'), + 'version' => '24.0.0', ]); + } + public function authenticate($credentials, $request): JsonResponse + { try { - $r = $client->post( - env('KEYCLOAK_BASE_URL').'/realms/'.env('KEYCLOAK_REALM').'/protocol/openid-connect/token', - [ - 'form_params' => [ - 'client_id' => env('KEYCLOAK_CLIENT_ID'), - 'client_secret' => env('KEYCLOAK_CLIENT_SECRET'), - 'username' => $request->email, - 'password' => $request->password, - 'grant_type' => 'password', - 'scope' => 'openid', - ], - ] - ); - } catch (\Exception $e) { - Log::error('Keycloak authentication failed. '.$e->getMessage()); + $accessTokenObject = $this->oauthProvider->getAccessToken('password', [ + 'username' => $request->email, + 'password' => $request->password, + 'scope' => 'openid', + ]); - return Authenticator::returnLoginError($request->email); - } + $resourceOwner = $this->oauthProvider->getResourceOwner($accessTokenObject); - $response = json_decode($r->getBody()->getContents(), true); - if (! isset($response['access_token'])) { - Log::error('Keycloak authentication failed. Access token is missing.'); + $roles = collect($this->kcUserApi->getRoles($resourceOwner->getId())) + ->map(function ($role) { + return $role->name; + })->toArray(); + } catch (\Exception $e) { + Log::error('Keycloak authentication failed. '.$e->getMessage()); return Authenticator::returnLoginError($request->email); } - $details = json_decode(base64_decode(str_replace('_', '/', str_replace('-', '+', explode('.', $response['access_token'])[1])))); $create = User::where('email', strtolower($request->email)) ->orWhere('username', strtolower($request->email)) @@ -52,28 +70,29 @@ public function authenticate($credentials, $request): JsonResponse if (! $create) { $user = User::create([ - 'id' => $details->sub, - 'name' => $details->name, - 'email' => $details->email, - 'username' => $details->preferred_username, + 'id' => $resourceOwner->getId(), + 'name' => $resourceOwner->getName(), + 'email' => $resourceOwner->getEmail(), + 'username' => $resourceOwner->getUsername(), 'auth_type' => 'keycloak', - 'password' => Hash::make(Str::random(16)), + 'password' => Hash::make(Str::uuid()), 'forceChange' => false, ]); } else { - $user = User::where('id', $details->sub)->first(); + $user = User::where('id', $resourceOwner->getId())->first(); } Oauth2Token::updateOrCreate([ - 'user_id' => $details->sub, - 'token_type' => $response['token_type'], + 'user_id' => $resourceOwner->getId(), + 'token_type' => $accessTokenObject->getValues()['token_type'], ], [ - 'user_id' => $details->sub, - 'token_type' => $response['token_type'], - 'access_token' => $response['access_token'], - 'refresh_token' => $response['refresh_token'], - 'expires_in' => (int) $response['expires_in'], - 'refresh_expires_in' => (int) $response['refresh_expires_in'], + 'user_id' => $resourceOwner->getId(), + 'token_type' => $accessTokenObject->getValues()['token_type'], + 'access_token' => $accessTokenObject->getToken(), + 'refresh_token' => $accessTokenObject->getRefreshToken(), + 'expires_in' => $accessTokenObject->getExpires(), + 'refresh_expires_in' => $accessTokenObject->getValues()['refresh_expires_in'], + 'permissions' => $roles, ]); return Authenticator::createNewToken( diff --git a/app/Models/Oauth2Token.php b/app/Models/Oauth2Token.php index 15d95aa2..31a30b29 100644 --- a/app/Models/Oauth2Token.php +++ b/app/Models/Oauth2Token.php @@ -20,6 +20,11 @@ class Oauth2Token extends Model 'token_type', 'expires_in', 'refresh_expires_in', + 'permissions', + ]; + + protected $casts = [ + 'permissions' => 'array', ]; /** diff --git a/composer.json b/composer.json index 0de4ac4f..908ce45d 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "ext-snmp": "*", "ext-xml": "*", "ext-zip": "*", + "acsystems/keycloak-php-sdk": "^4.4", "ankitpokhrel/tus-php": "^2.3", "bacon/bacon-qr-code": "^2.0", "beebmx/blade": "^1.5", @@ -31,6 +32,7 @@ "phpseclib/phpseclib": "~3.0", "pragmarx/google2fa-laravel": "^2.0", "pusher/pusher-php-server": "^7.0", + "stevenmaguire/oauth2-keycloak": "^5.1", "tymon/jwt-auth": "^2.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 267463b4..d6117dd5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,64 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a8482a825b5578ff9a92f296f2625348", + "content-hash": "d3cb2377990fcc236ec311057e645dbb", "packages": [ + { + "name": "acsystems/keycloak-php-sdk", + "version": "4.4.0", + "source": { + "type": "git", + "url": "https://bitbucket.org/acwebdev/keycloak-php-sdk.git", + "reference": "29545022aea3f317bdbb0539f160914c6afb2183" + }, + "dist": { + "type": "zip", + "url": "https://bitbucket.org/acwebdev/keycloak-php-sdk/get/29545022aea3f317bdbb0539f160914c6afb2183.zip", + "reference": "29545022aea3f317bdbb0539f160914c6afb2183", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^7.7", + "league/oauth2-client": "^2.7", + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "roave/security-advisories": "dev-latest", + "symfony/dotenv": "^v5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Keycloak\\": "src/", + "App\\Tests\\": "tests" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "WebDev", + "email": "web.support@ac-systems.com", + "homepage": "https://ac-systems.be" + } + ], + "description": "PHP wrapper for the KeyCloak admin API", + "homepage": "https://bitbucket.org/acwebdev/keycloak-php-sdk", + "keywords": [ + "admin", + "identity server", + "keycloak", + "sdk" + ], + "support": { + "source": "https://bitbucket.org/acwebdev/keycloak-php-sdk/src/29545022aea3f317bdbb0539f160914c6afb2183/?at=4.4.0" + }, + "time": "2024-01-08T10:04:09+00:00" + }, { "name": "ankitpokhrel/tus-php", "version": "v2.4.0", @@ -1463,6 +1519,69 @@ }, "time": "2020-11-24T22:02:12+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.10.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "500501c2ce893c824c801da135d02661199f60c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/500501c2ce893c824c801da135d02661199f60c5", + "reference": "500501c2ce893c824c801da135d02661199f60c5", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.10.1" + }, + "time": "2024-05-18T18:05:11+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.3.0", @@ -3419,6 +3538,76 @@ ], "time": "2024-01-28T23:22:08+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "160d6274b03562ebeb55ed18399281d8118b76c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/160d6274b03562ebeb55ed18399281d8118b76c8", + "reference": "160d6274b03562ebeb55ed18399281d8118b76c8", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "paragonie/random_compat": "^1 || ^2 || ^9.99", + "php": "^5.6 || ^7.0 || ^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.5", + "php-parallel-lint/php-parallel-lint": "^1.3.1", + "phpunit/phpunit": "^5.7 || ^6.0 || ^9.5", + "squizlabs/php_codesniffer": "^2.3 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.7.0" + }, + "time": "2023-04-16T18:19:15+00:00" + }, { "name": "limanmys/php-smb", "version": "3.5.1", @@ -6148,6 +6337,67 @@ ], "time": "2024-06-11T12:45:25+00:00" }, + { + "name": "stevenmaguire/oauth2-keycloak", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/stevenmaguire/oauth2-keycloak.git", + "reference": "1b690b7377dfe7a23e1590373f37e12cf40a6d75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stevenmaguire/oauth2-keycloak/zipball/1b690b7377dfe7a23e1590373f37e12cf40a6d75", + "reference": "1b690b7377dfe7a23e1590373f37e12cf40a6d75", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^6.0", + "league/oauth2-client": "^2.0", + "php": "~7.2 || ~8.0" + }, + "require-dev": { + "mockery/mockery": "~1.5.0", + "phpunit/phpunit": "~9.6.4", + "squizlabs/php_codesniffer": "~3.7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Stevenmaguire\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steven Maguire", + "email": "stevenmaguire@gmail.com", + "homepage": "https://github.com/stevenmaguire" + } + ], + "description": "Keycloak OAuth 2.0 Client Provider for The PHP League OAuth2-Client", + "keywords": [ + "authorisation", + "authorization", + "client", + "keycloak", + "oauth", + "oauth2" + ], + "support": { + "issues": "https://github.com/stevenmaguire/oauth2-keycloak/issues", + "source": "https://github.com/stevenmaguire/oauth2-keycloak/tree/5.1.0" + }, + "time": "2023-10-24T06:10:44+00:00" + }, { "name": "symfony/console", "version": "v6.4.11", diff --git a/database/migrations/2024_09_10_142633_add_column_permissions_to_oauth2_tokens_table.php b/database/migrations/2024_09_10_142633_add_column_permissions_to_oauth2_tokens_table.php new file mode 100644 index 00000000..df4c29d6 --- /dev/null +++ b/database/migrations/2024_09_10_142633_add_column_permissions_to_oauth2_tokens_table.php @@ -0,0 +1,28 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('oauth2_tokens', function (Blueprint $table) { + $table->json('permissions')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth2_tokens', function (Blueprint $table) { + $table->dropColumn('permissions'); + }); + } +};