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