Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 181 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
| KeycloakInstance | |
0.00% |
0 / 181 |
|
0.00% |
0 / 11 |
1122 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| getProvider | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| doLogin | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
12 | |||
| doLogout | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| writeNewAccessTokenIfExpired | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
30 | |||
| validateAccess | |
0.00% |
0 / 83 |
|
0.00% |
0 / 1 |
156 | |||
| validateOwnerData | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
| getAccessToken | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
12 | |||
| writeTokenToSession | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
| writeDeleteSession | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
| readTokenDataFromSession | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace BO\Slim\Middleware\OAuth; |
| 4 | |
| 5 | use Psr\Http\Message\ServerRequestInterface; |
| 6 | use Psr\Http\Message\ResponseInterface; |
| 7 | use League\OAuth2\Client\Token\AccessToken; |
| 8 | |
| 9 | /** |
| 10 | * @SuppressWarnings(PHPMD) |
| 11 | */ |
| 12 | class KeycloakInstance |
| 13 | { |
| 14 | protected $provider = null; |
| 15 | protected $oauthService = null; |
| 16 | |
| 17 | public function __construct(?\BO\Zmsclient\OAuthService $oauthService = null) |
| 18 | { |
| 19 | $this->oauthService = $oauthService ?: new \BO\Zmsclient\OAuthService(\App::$http, \App::CONFIG_SECURE_TOKEN); |
| 20 | $this->provider = new Keycloak\Provider(null, $this->oauthService); |
| 21 | } |
| 22 | |
| 23 | public function getProvider() |
| 24 | { |
| 25 | return $this->provider; |
| 26 | } |
| 27 | |
| 28 | public function doLogin(ServerRequestInterface $request, ResponseInterface $response) |
| 29 | { |
| 30 | \App::$log->info('OIDC login attempt', [ |
| 31 | 'event' => 'oauth_login_start', |
| 32 | 'timestamp' => date('c') |
| 33 | ]); |
| 34 | |
| 35 | try { |
| 36 | $accessToken = $this->getAccessToken($request->getQueryParams()["code"] ?? ''); |
| 37 | $this->validateAccess($accessToken); |
| 38 | $ownerInputData = $this->provider->getResourceOwnerData($accessToken); |
| 39 | $this->validateOwnerData((array) $ownerInputData); |
| 40 | |
| 41 | if (\BO\Zmsclient\Auth::getKey()) { |
| 42 | \App::$log->info('Clearing existing session', [ |
| 43 | 'event' => 'oauth_session_clear', |
| 44 | 'timestamp' => date('c') |
| 45 | ]); |
| 46 | $this->writeDeleteSession(); |
| 47 | } |
| 48 | |
| 49 | $this->writeTokenToSession($accessToken); |
| 50 | $this->oauthService->authenticateWorkstation($ownerInputData, \BO\Zmsclient\Auth::getKey()); |
| 51 | |
| 52 | \App::$log->info('OIDC login successful', [ |
| 53 | 'event' => 'oauth_login_success', |
| 54 | 'timestamp' => date('c') |
| 55 | ]); |
| 56 | } catch (\BO\Zmsclient\Exception $exception) { |
| 57 | \App::$log->error('OIDC login failed', [ |
| 58 | 'event' => 'oauth_login_error', |
| 59 | 'timestamp' => date('c'), |
| 60 | 'error' => $exception->getMessage() |
| 61 | ]); |
| 62 | $this->writeDeleteSession(); |
| 63 | \BO\Zmsclient\Auth::removeKey(); |
| 64 | \BO\Zmsclient\Auth::removeOidcProvider(); |
| 65 | throw $exception; |
| 66 | } |
| 67 | return $response; |
| 68 | } |
| 69 | |
| 70 | public function doLogout(ResponseInterface $response) |
| 71 | { |
| 72 | $this->writeDeleteSession(); |
| 73 | $realmData = $this->provider->getBasicOptionsFromJsonFile(); |
| 74 | return $response->withStatus(301)->withHeader('Location', $realmData['logoutUri']); |
| 75 | } |
| 76 | |
| 77 | public function writeNewAccessTokenIfExpired() |
| 78 | { |
| 79 | try { |
| 80 | $accessTokenData = $this->readTokenDataFromSession(); |
| 81 | $accessTokenData = (is_array($accessTokenData)) ? $accessTokenData : []; |
| 82 | $existingAccessToken = new AccessToken($accessTokenData); |
| 83 | if ($existingAccessToken && $existingAccessToken->hasExpired()) { |
| 84 | $newAccessToken = $this->provider->getAccessToken('refresh_token', [ |
| 85 | 'refresh_token' => $existingAccessToken->getRefreshToken() |
| 86 | ]); |
| 87 | $this->writeDeleteSession(); |
| 88 | $this->writeTokenToSession($newAccessToken); |
| 89 | } |
| 90 | } catch (\Exception $exception) { |
| 91 | return false; |
| 92 | } |
| 93 | return true; |
| 94 | } |
| 95 | |
| 96 | private function validateAccess(AccessToken $token) |
| 97 | { |
| 98 | \App::$log->info('Validating OIDC token', [ |
| 99 | 'event' => 'oauth_token_validation', |
| 100 | 'timestamp' => date('c') |
| 101 | ]); |
| 102 | |
| 103 | list($header, $payload, $signature) = explode('.', $token->getToken()); |
| 104 | |
| 105 | if (empty($header)) { |
| 106 | \App::$log->error('Token validation failed', [ |
| 107 | 'event' => 'oauth_token_validation_failed', |
| 108 | 'timestamp' => date('c'), |
| 109 | 'reason' => 'missing_header' |
| 110 | ]); |
| 111 | throw new \BO\Slim\Exception\OAuthFailed(); |
| 112 | } |
| 113 | if (empty($payload)) { |
| 114 | \App::$log->error('Token validation failed', [ |
| 115 | 'event' => 'oauth_token_validation_failed', |
| 116 | 'timestamp' => date('c'), |
| 117 | 'reason' => 'missing_payload' |
| 118 | ]); |
| 119 | throw new \BO\Slim\Exception\OAuthFailed(); |
| 120 | } |
| 121 | if (empty($signature)) { |
| 122 | \App::$log->error('Token validation failed', [ |
| 123 | 'event' => 'oauth_token_validation_failed', |
| 124 | 'timestamp' => date('c'), |
| 125 | 'reason' => 'missing_signature' |
| 126 | ]); |
| 127 | throw new \BO\Slim\Exception\OAuthFailed(); |
| 128 | } |
| 129 | |
| 130 | $realmData = $this->provider->getBasicOptionsFromJsonFile(); |
| 131 | |
| 132 | // Fix: Properly handle base64url encoding before JSON decoding |
| 133 | $payload = str_replace(['-', '_'], ['+', '/'], $payload); |
| 134 | $payload = base64_decode($payload . str_repeat('=', 4 - (strlen($payload) % 4))); |
| 135 | $accessTokenPayload = json_decode($payload, true); |
| 136 | |
| 137 | $clientRoles = array(); |
| 138 | |
| 139 | if ($accessTokenPayload === null) { |
| 140 | \App::$log->error('Token validation failed', [ |
| 141 | 'event' => 'oauth_token_validation_failed', |
| 142 | 'timestamp' => date('c'), |
| 143 | 'reason' => 'invalid_payload_json', |
| 144 | 'error' => json_last_error_msg() |
| 145 | ]); |
| 146 | throw new \BO\Slim\Exception\OAuthFailed(); |
| 147 | } |
| 148 | |
| 149 | if (!isset($accessTokenPayload['resource_access']) || !is_array($accessTokenPayload['resource_access'])) { |
| 150 | \App::$log->error('Token validation failed', [ |
| 151 | 'event' => 'oauth_token_validation_failed', |
| 152 | 'timestamp' => date('c'), |
| 153 | 'reason' => 'invalid_resource_access', |
| 154 | 'has_resource_access' => isset($accessTokenPayload['resource_access']), |
| 155 | 'resource_access_type' => gettype($accessTokenPayload['resource_access'] ?? null) |
| 156 | ]); |
| 157 | throw new \BO\Slim\Exception\OAuthFailed(); |
| 158 | } |
| 159 | |
| 160 | if (!isset($accessTokenPayload['resource_access'][\App::IDENTIFIER])) { |
| 161 | \App::$log->error('Token validation failed', [ |
| 162 | 'event' => 'oauth_token_validation_failed', |
| 163 | 'timestamp' => date('c'), |
| 164 | 'reason' => 'missing_app_identifier', |
| 165 | 'app_identifier' => \App::IDENTIFIER, |
| 166 | 'available_resources' => array_keys($accessTokenPayload['resource_access']) |
| 167 | ]); |
| 168 | throw new \BO\Slim\Exception\OAuthFailed(); |
| 169 | } |
| 170 | |
| 171 | $resourceAccess = $accessTokenPayload['resource_access']; |
| 172 | $appIdentifierRoles = $resourceAccess[\App::IDENTIFIER]['roles'] ?? null; |
| 173 | |
| 174 | if (!$appIdentifierRoles || !is_array($appIdentifierRoles)) { |
| 175 | \App::$log->error('Token validation failed', [ |
| 176 | 'event' => 'oauth_token_validation_failed', |
| 177 | 'timestamp' => date('c'), |
| 178 | 'reason' => 'invalid_roles', |
| 179 | 'has_roles' => isset($resourceAccess[\App::IDENTIFIER]['roles']), |
| 180 | 'roles_type' => gettype($appIdentifierRoles) |
| 181 | ]); |
| 182 | throw new \BO\Slim\Exception\OAuthFailed(); |
| 183 | } |
| 184 | |
| 185 | if (is_array($accessTokenPayload['resource_access'])) { |
| 186 | $clientRoles = array_values($accessTokenPayload['resource_access'][\App::IDENTIFIER]['roles']); |
| 187 | } |
| 188 | |
| 189 | if (!in_array($realmData['accessRole'], $clientRoles)) { |
| 190 | \App::$log->error('Token validation failed', [ |
| 191 | 'event' => 'oauth_token_validation_failed', |
| 192 | 'timestamp' => date('c'), |
| 193 | 'reason' => 'missing_required_role', |
| 194 | 'required_role' => $realmData['accessRole'], |
| 195 | 'available_roles' => $clientRoles |
| 196 | ]); |
| 197 | throw new \BO\Slim\Exception\OAuthFailed(); |
| 198 | } |
| 199 | |
| 200 | \App::$log->info('Token validation successful', [ |
| 201 | 'event' => 'oauth_token_validation_success', |
| 202 | 'timestamp' => date('c') |
| 203 | ]); |
| 204 | } |
| 205 | |
| 206 | private function validateOwnerData(array $ownerInputData) |
| 207 | { |
| 208 | $config = $this->oauthService->readConfig(); |
| 209 | if (! \array_key_exists('email', $ownerInputData) && 1 == $config->getPreference('oidc', 'onlyVerifiedMail')) { |
| 210 | throw new \BO\Slim\Exception\OAuthPreconditionFailed(); |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | private function getAccessToken($code) |
| 215 | { |
| 216 | \App::$log->info('Getting access token', [ |
| 217 | 'event' => 'oauth_get_token', |
| 218 | 'timestamp' => date('c') |
| 219 | ]); |
| 220 | |
| 221 | try { |
| 222 | $accessToken = $this->provider->getAccessToken('authorization_code', ['code' => $code]); |
| 223 | \App::$log->info('Access token obtained', [ |
| 224 | 'event' => 'oauth_get_token_success', |
| 225 | 'timestamp' => date('c') |
| 226 | ]); |
| 227 | return $accessToken; |
| 228 | } catch (\Exception $exception) { |
| 229 | \App::$log->error('Failed to get access token', [ |
| 230 | 'event' => 'oauth_get_token_error', |
| 231 | 'timestamp' => date('c'), |
| 232 | 'error' => $exception->getMessage(), |
| 233 | 'exception_class' => get_class($exception) |
| 234 | ]); |
| 235 | if ('League\OAuth2\Client\Provider\Exception\IdentityProviderException' === get_class($exception)) { |
| 236 | throw new \BO\Slim\Exception\OAuthFailed(); |
| 237 | } |
| 238 | throw $exception; |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | private function writeTokenToSession($token) |
| 243 | { |
| 244 | \App::$log->info('Writing token to session', [ |
| 245 | 'event' => 'oauth_write_token', |
| 246 | 'timestamp' => date('c') |
| 247 | ]); |
| 248 | |
| 249 | $realmData = $this->provider->getBasicOptionsFromJsonFile(); |
| 250 | $sessionHandler = (new \BO\Zmsclient\SessionHandler(\App::$http)); |
| 251 | $sessionHandler->open('/' . $realmData['realm'] . '/', $realmData['clientId']); |
| 252 | $sessionHandler->write(\BO\Zmsclient\Auth::getKey(), serialize($token), ['oidc' => true]); |
| 253 | return $sessionHandler->close(); |
| 254 | } |
| 255 | |
| 256 | private function writeDeleteSession() |
| 257 | { |
| 258 | \App::$log->info('Deleting session', [ |
| 259 | 'event' => 'oauth_delete_session', |
| 260 | 'timestamp' => date('c') |
| 261 | ]); |
| 262 | |
| 263 | $realmData = $this->provider->getBasicOptionsFromJsonFile(); |
| 264 | $sessionHandler = (new \BO\Zmsclient\SessionHandler(\App::$http)); |
| 265 | $sessionHandler->open('/' . $realmData['realm'] . '/', $realmData['clientId']); |
| 266 | $sessionHandler->destroy(\BO\Zmsclient\Auth::getKey()); |
| 267 | } |
| 268 | |
| 269 | private function readTokenDataFromSession() |
| 270 | { |
| 271 | \App::$log->info('Reading token from session', [ |
| 272 | 'event' => 'oauth_read_token', |
| 273 | 'timestamp' => date('c') |
| 274 | ]); |
| 275 | |
| 276 | $realmData = $this->provider->getBasicOptionsFromJsonFile(); |
| 277 | $sessionHandler = (new \BO\Zmsclient\SessionHandler(\App::$http)); |
| 278 | $sessionHandler->open('/' . $realmData['realm'] . '/', $realmData['clientId']); |
| 279 | $tokenData = unserialize($sessionHandler->read(\BO\Zmsclient\Auth::getKey(), ['oidc' => true])); |
| 280 | return $tokenData; |
| 281 | } |
| 282 | } |