Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
179 / 179 |
|
100.00% |
11 / 11 |
CRAP | |
100.00% |
1 / 1 |
OAuthController | |
100.00% |
179 / 179 |
|
100.00% |
11 / 11 |
37 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
index | |
100.00% |
87 / 87 |
|
100.00% |
1 / 1 |
20 | |||
connect | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
disconnect | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getProvider | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
getId | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
requireProvider | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
isValidProvider | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
handleArrive | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
2 | |||
handleOAuthError | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
redirectRegister | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
4 |
1 | <?php |
2 | |
3 | declare(strict_types=1); |
4 | |
5 | namespace Engelsystem\Controllers; |
6 | |
7 | use Carbon\Carbon; |
8 | use Engelsystem\Config\Config; |
9 | use Engelsystem\Helpers\Authenticator; |
10 | use Engelsystem\Http\Exceptions\HttpNotFound; |
11 | use Engelsystem\Http\Redirector; |
12 | use Engelsystem\Http\Request; |
13 | use Engelsystem\Http\Response; |
14 | use Engelsystem\Http\UrlGenerator; |
15 | use Engelsystem\Models\OAuth; |
16 | use Illuminate\Support\Arr; |
17 | use Illuminate\Support\Collection; |
18 | use League\OAuth2\Client\Provider\AbstractProvider; |
19 | use League\OAuth2\Client\Provider\Exception\IdentityProviderException; |
20 | use League\OAuth2\Client\Provider\GenericProvider; |
21 | use League\OAuth2\Client\Provider\ResourceOwnerInterface as ResourceOwner; |
22 | use League\OAuth2\Client\Token\AccessTokenInterface; |
23 | use Psr\Log\LoggerInterface; |
24 | use Symfony\Component\HttpFoundation\Session\Session as Session; |
25 | |
26 | class OAuthController extends BaseController |
27 | { |
28 | use HasUserNotifications; |
29 | |
30 | public function __construct( |
31 | protected Authenticator $auth, |
32 | protected AuthController $authController, |
33 | protected Config $config, |
34 | protected LoggerInterface $log, |
35 | protected OAuth $oauth, |
36 | protected Redirector $redirect, |
37 | protected Session $session, |
38 | protected UrlGenerator $url |
39 | ) { |
40 | } |
41 | |
42 | public function index(Request $request): Response |
43 | { |
44 | $providerName = $request->getAttribute('provider'); |
45 | |
46 | $provider = $this->getProvider($providerName); |
47 | $config = $this->config->get('oauth')[$providerName]; |
48 | |
49 | // Handle OAuth error response according to https://www.rfc-editor.org/rfc/rfc6749#section-4.1.2.1 |
50 | if ($request->has('error')) { |
51 | throw new HttpNotFound('oauth.' . $request->get('error')); |
52 | } |
53 | |
54 | // Initial request redirects to provider |
55 | if (!$request->has('code')) { |
56 | $authorizationUrl = $provider->getAuthorizationUrl( |
57 | [ |
58 | // League oauth separates scopes by comma, which is wrong, so we do it |
59 | // here properly by spaces. See https://www.rfc-editor.org/rfc/rfc6749#section-3.3 |
60 | 'scope' => join(' ', $config['scope'] ?? []), |
61 | ] |
62 | ); |
63 | $this->session->set('oauth2_state', $provider->getState()); |
64 | |
65 | return $this->redirect->to($authorizationUrl); |
66 | } |
67 | |
68 | // Redirected URL got called a second time |
69 | if ( |
70 | !$this->session->get('oauth2_state') |
71 | || $request->get('state') !== $this->session->get('oauth2_state') |
72 | ) { |
73 | $this->session->remove('oauth2_state'); |
74 | |
75 | $this->log->warning('Invalid OAuth state'); |
76 | |
77 | throw new HttpNotFound('oauth.invalid-state'); |
78 | } |
79 | |
80 | // Fetch access token |
81 | $accessToken = null; |
82 | try { |
83 | $accessToken = $provider->getAccessToken( |
84 | 'authorization_code', |
85 | [ |
86 | 'code' => $request->get('code'), |
87 | ] |
88 | ); |
89 | } catch (IdentityProviderException $e) { |
90 | $this->handleOAuthError($e, $providerName); |
91 | } |
92 | |
93 | // Load resource identifier |
94 | $resourceOwner = null; |
95 | try { |
96 | $resourceOwner = $provider->getResourceOwner($accessToken); |
97 | } catch (IdentityProviderException $e) { |
98 | $this->handleOAuthError($e, $providerName); |
99 | } |
100 | $resourceId = $this->getId($providerName, $resourceOwner); |
101 | |
102 | // Fetch existing oauth state |
103 | /** @var OAuth|null $oauth */ |
104 | $oauth = $this->oauth |
105 | ->query() |
106 | ->where('provider', $providerName) |
107 | ->where('identifier', $resourceId) |
108 | ->get() |
109 | // Explicit case-sensitive comparison using PHP as some DBMS collations are case-sensitive and some aren't |
110 | ->where('identifier', '===', (string) $resourceId) |
111 | ->first(); |
112 | |
113 | // Update oauth state |
114 | $expirationTime = $accessToken->getExpires(); |
115 | $expirationTime = $expirationTime |
116 | ? Carbon::createFromTimestamp($expirationTime, Carbon::now()->timezone) |
117 | : null; |
118 | if ($oauth) { |
119 | $oauth->access_token = $accessToken->getToken(); |
120 | $oauth->refresh_token = $accessToken->getRefreshToken(); |
121 | $oauth->expires_at = $expirationTime; |
122 | |
123 | $oauth->save(); |
124 | } |
125 | |
126 | // Load user |
127 | $user = $this->auth->user(); |
128 | if ($oauth && $user && $user->id != $oauth->user_id) { |
129 | throw new HttpNotFound('oauth.already-connected'); |
130 | } |
131 | |
132 | $connectProvider = $this->session->get('oauth2_connect_provider'); |
133 | $this->session->remove('oauth2_connect_provider'); |
134 | // Connect user with oauth |
135 | if (!$oauth && $user && $connectProvider && $connectProvider == $providerName) { |
136 | $oauth = new OAuth([ |
137 | 'provider' => $providerName, |
138 | 'identifier' => $resourceId, |
139 | 'access_token' => $accessToken->getToken(), |
140 | 'refresh_token' => $accessToken->getRefreshToken(), |
141 | 'expires_at' => $expirationTime, |
142 | ]); |
143 | $oauth->user() |
144 | ->associate($user) |
145 | ->save(); |
146 | |
147 | $this->log->info( |
148 | 'Connected OAuth user {user} using {provider}', |
149 | ['provider' => $providerName, 'user' => $resourceId] |
150 | ); |
151 | $this->addNotification('oauth.connected'); |
152 | } |
153 | |
154 | // Load user data |
155 | $resourceData = $resourceOwner->toArray(); |
156 | if (!empty($config['nested_info'])) { |
157 | $resourceData = Arr::dot($resourceData); |
158 | } |
159 | |
160 | $userdata = new Collection($resourceData); |
161 | if (!$oauth) { |
162 | // User authenticated but has no account |
163 | return $this->redirectRegister( |
164 | $providerName, |
165 | (string) $resourceId, |
166 | $accessToken, |
167 | $config, |
168 | $userdata |
169 | ); |
170 | } |
171 | |
172 | if (isset($config['mark_arrived']) && $config['mark_arrived']) { |
173 | $this->handleArrive($providerName, $oauth, $resourceOwner); |
174 | } |
175 | |
176 | $response = $this->authController->loginUser($oauth->user); |
177 | event('oauth2.login', ['provider' => $providerName, 'data' => $userdata]); |
178 | |
179 | return $response; |
180 | } |
181 | |
182 | public function connect(Request $request): Response |
183 | { |
184 | $providerName = $request->getAttribute('provider'); |
185 | |
186 | $this->requireProvider($providerName); |
187 | |
188 | $this->session->set('oauth2_connect_provider', $providerName); |
189 | |
190 | return $this->index($request); |
191 | } |
192 | |
193 | public function disconnect(Request $request): Response |
194 | { |
195 | $providerName = $request->getAttribute('provider'); |
196 | |
197 | $this->oauth |
198 | ->whereUserId($this->auth->user()->id) |
199 | ->where('provider', $providerName) |
200 | ->delete(); |
201 | |
202 | $this->log->info('Disconnected OAuth from {provider}', ['provider' => $providerName]); |
203 | $this->addNotification('oauth.disconnected'); |
204 | |
205 | return $this->redirect->back(); |
206 | } |
207 | |
208 | protected function getProvider(string $name): AbstractProvider |
209 | { |
210 | $this->requireProvider($name); |
211 | $config = $this->config->get('oauth')[$name]; |
212 | |
213 | return new GenericProvider( |
214 | [ |
215 | 'clientId' => $config['client_id'], |
216 | 'clientSecret' => $config['client_secret'], |
217 | 'redirectUri' => $this->url->to('oauth/' . $name), |
218 | 'urlAuthorize' => $config['url_auth'], |
219 | 'urlAccessToken' => $config['url_token'], |
220 | 'urlResourceOwnerDetails' => $config['url_info'], |
221 | 'responseResourceOwnerId' => $config['id'], |
222 | ] |
223 | ); |
224 | } |
225 | |
226 | protected function getId(string $providerName, ResourceOwner $resourceOwner): mixed |
227 | { |
228 | $config = $this->config->get('oauth')[$providerName]; |
229 | if (empty($config['nested_info'])) { |
230 | return $resourceOwner->getId(); |
231 | } |
232 | |
233 | $data = Arr::dot($resourceOwner->toArray()); |
234 | return $data[$config['id']]; |
235 | } |
236 | |
237 | protected function requireProvider(string $provider): void |
238 | { |
239 | if (!$this->isValidProvider($provider)) { |
240 | throw new HttpNotFound('oauth.provider-not-found'); |
241 | } |
242 | } |
243 | |
244 | protected function isValidProvider(string $name): bool |
245 | { |
246 | $config = $this->config->get('oauth'); |
247 | |
248 | return isset($config[$name]); |
249 | } |
250 | |
251 | protected function handleArrive( |
252 | string $providerName, |
253 | OAuth $auth, |
254 | ResourceOwner $resourceOwner |
255 | ): void { |
256 | $user = $auth->user; |
257 | $userState = $user->state; |
258 | |
259 | if ($userState->arrived) { |
260 | return; |
261 | } |
262 | |
263 | $userState->arrived = true; |
264 | $userState->arrival_date = new Carbon(); |
265 | $userState->save(); |
266 | |
267 | $this->log->info( |
268 | 'Set user {name} ({id}) as arrived via {provider} user {user}', |
269 | [ |
270 | 'provider' => $providerName, |
271 | 'user' => $this->getId($providerName, $resourceOwner), |
272 | 'name' => $user->name, |
273 | 'id' => $user->id, |
274 | ] |
275 | ); |
276 | } |
277 | |
278 | /** |
279 | * |
280 | * @throws HttpNotFound |
281 | */ |
282 | protected function handleOAuthError(IdentityProviderException $e, string $providerName): void |
283 | { |
284 | $response = $e->getResponseBody(); |
285 | $response = is_array($response) ? json_encode($response) : $response; |
286 | $this->log->error( |
287 | '{provider} identity provider error: {error} {description}', |
288 | [ |
289 | 'provider' => $providerName, |
290 | 'error' => $e->getMessage(), |
291 | 'description' => $response, |
292 | ] |
293 | ); |
294 | |
295 | throw new HttpNotFound('oauth.provider-error'); |
296 | } |
297 | |
298 | protected function redirectRegister( |
299 | string $providerName, |
300 | string $providerUserIdentifier, |
301 | AccessTokenInterface $accessToken, |
302 | array $config, |
303 | Collection $userdata |
304 | ): Response { |
305 | $config = array_merge( |
306 | [ |
307 | 'username' => null, |
308 | 'email' => null, |
309 | 'first_name' => null, |
310 | 'last_name' => null, |
311 | 'enable_password' => false, |
312 | 'allow_registration' => null, |
313 | 'groups' => null, |
314 | ], |
315 | $config |
316 | ); |
317 | |
318 | if (!$this->config->get('registration_enabled') && !$config['allow_registration']) { |
319 | throw new HttpNotFound('oauth.not-found'); |
320 | } |
321 | |
322 | // Set registration form field data |
323 | $this->session->set('form-data-username', $userdata->get($config['username'])); |
324 | $this->session->set('form-data-email', $userdata->get($config['email'])); |
325 | $this->session->set('form-data-firstname', $userdata->get($config['first_name'])); |
326 | $this->session->set('form-data-lastname', $userdata->get($config['last_name'])); |
327 | |
328 | // Define OAuth state |
329 | $this->session->set('oauth2_groups', $userdata->get($config['groups'], [])); |
330 | $this->session->set('oauth2_connect_provider', $providerName); |
331 | $this->session->set('oauth2_user_id', $providerUserIdentifier); |
332 | |
333 | $timezone = Carbon::now()->timezone; |
334 | $expirationTime = $accessToken->getExpires(); |
335 | $expirationTime = $expirationTime ? Carbon::createFromTimestamp($expirationTime, $timezone) : null; |
336 | $this->session->set('oauth2_access_token', $accessToken->getToken()); |
337 | $this->session->set('oauth2_refresh_token', $accessToken->getRefreshToken()); |
338 | $this->session->set('oauth2_expires_at', $expirationTime); |
339 | $this->session->set('oauth2_enable_password', $config['enable_password']); |
340 | $this->session->set('oauth2_allow_registration', $config['allow_registration']); |
341 | |
342 | return $this->redirect->to('/register'); |
343 | } |
344 | } |