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 | } |