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