Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
181 / 181
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
OAuthController
100.00% covered (success)
100.00%
181 / 181
100.00% covered (success)
100.00%
11 / 11
38
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 index
100.00% covered (success)
100.00%
87 / 87
100.00% covered (success)
100.00%
1 / 1
20
 connect
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 disconnect
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getProvider
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 requireProvider
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isValidProvider
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 handleArrive
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 handleOAuthError
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 redirectRegister
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Engelsystem\Controllers;
6
7use Carbon\Carbon;
8use Engelsystem\Config\Config;
9use Engelsystem\Helpers\Authenticator;
10use Engelsystem\Http\Exceptions\HttpNotFound;
11use Engelsystem\Http\Redirector;
12use Engelsystem\Http\Request;
13use Engelsystem\Http\Response;
14use Engelsystem\Http\UrlGenerator;
15use Engelsystem\Models\OAuth;
16use Illuminate\Support\Arr;
17use Illuminate\Support\Collection;
18use League\OAuth2\Client\Provider\AbstractProvider;
19use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
20use League\OAuth2\Client\Provider\GenericProvider;
21use League\OAuth2\Client\Provider\ResourceOwnerInterface as ResourceOwner;
22use League\OAuth2\Client\Token\AccessTokenInterface;
23use Psr\Log\LoggerInterface;
24use Symfony\Component\HttpFoundation\Session\Session as Session;
25
26class 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->requireProvider($providerName);
198
199        if (!($this->config->get('oauth')[$providerName]['allow_user_disconnect'] ?? true)) {
200            throw new HttpNotFound();
201        }
202
203        $this->oauth
204            ->whereUserId($this->auth->user()->id)
205            ->where('provider', $providerName)
206            ->delete();
207
208        $this->log->info('Disconnected OAuth from {provider}', ['provider' => $providerName]);
209        $this->addNotification('oauth.disconnected');
210
211        return $this->redirect->back();
212    }
213
214    protected function getProvider(string $name): AbstractProvider
215    {
216        $this->requireProvider($name);
217        $config = $this->config->get('oauth')[$name];
218
219        return new GenericProvider(
220            [
221                'clientId'                => $config['client_id'],
222                'clientSecret'            => $config['client_secret'],
223                'redirectUri'             => $this->url->to('oauth/' . $name),
224                'urlAuthorize'            => $config['url_auth'],
225                'urlAccessToken'          => $config['url_token'],
226                'urlResourceOwnerDetails' => $config['url_info'],
227                'responseResourceOwnerId' => $config['id'],
228            ]
229        );
230    }
231
232    protected function getId(string $providerName, ResourceOwner $resourceOwner): mixed
233    {
234        $config = $this->config->get('oauth')[$providerName];
235        if (empty($config['nested_info'])) {
236            return $resourceOwner->getId();
237        }
238
239        $data = Arr::dot($resourceOwner->toArray());
240        return $data[$config['id']];
241    }
242
243    protected function requireProvider(string $provider): void
244    {
245        if (!$this->isValidProvider($provider)) {
246            throw new HttpNotFound('oauth.provider-not-found');
247        }
248    }
249
250    protected function isValidProvider(string $name): bool
251    {
252        $config = $this->config->get('oauth');
253
254        return isset($config[$name]);
255    }
256
257    protected function handleArrive(
258        string $providerName,
259        OAuth $auth,
260        ResourceOwner $resourceOwner
261    ): void {
262        $user = $auth->user;
263        $userState = $user->state;
264
265        if ($userState->arrived) {
266            return;
267        }
268
269        $userState->arrival_date = new Carbon();
270        $userState->save();
271
272        $this->log->info(
273            'Set user {name} ({id}) as arrived via {provider} user {user}',
274            [
275                'provider' => $providerName,
276                'user'     => $this->getId($providerName, $resourceOwner),
277                'name'     => $user->name,
278                'id'       => $user->id,
279            ]
280        );
281    }
282
283    /**
284     *
285     * @throws HttpNotFound
286     */
287    protected function handleOAuthError(IdentityProviderException $e, string $providerName): void
288    {
289        $response = $e->getResponseBody();
290        $response = is_array($response) ? json_encode($response) : $response;
291        $this->log->error(
292            '{provider} identity provider error: {error} {description}',
293            [
294                'provider'    => $providerName,
295                'error'       => $e->getMessage(),
296                'description' => $response,
297            ]
298        );
299
300        throw new HttpNotFound('oauth.provider-error');
301    }
302
303    protected function redirectRegister(
304        string $providerName,
305        string $providerUserIdentifier,
306        AccessTokenInterface $accessToken,
307        array $config,
308        Collection $userdata
309    ): Response {
310        $config = array_merge(
311            [
312                'username'           => null,
313                'email'              => null,
314                'first_name'         => null,
315                'last_name'          => null,
316                'enable_password'    => false,
317                'allow_registration' => null,
318                'groups'             => null,
319            ],
320            $config
321        );
322
323        if (!$this->config->get('registration_enabled') && !$config['allow_registration']) {
324            throw new HttpNotFound('oauth.not-found');
325        }
326
327        // Set registration form field data
328        $this->session->set('form-data-username', $userdata->get($config['username']));
329        $this->session->set('form-data-email', $userdata->get($config['email']));
330        $this->session->set('form-data-firstname', $userdata->get($config['first_name']));
331        $this->session->set('form-data-lastname', $userdata->get($config['last_name']));
332
333        // Define OAuth state
334        $this->session->set('oauth2_groups', $userdata->get($config['groups'], []));
335        $this->session->set('oauth2_connect_provider', $providerName);
336        $this->session->set('oauth2_user_id', $providerUserIdentifier);
337
338        $timezone = Carbon::now()->timezone;
339        $expirationTime = $accessToken->getExpires();
340        $expirationTime = $expirationTime ? Carbon::createFromTimestamp($expirationTime, $timezone) : null;
341        $this->session->set('oauth2_access_token', $accessToken->getToken());
342        $this->session->set('oauth2_refresh_token', $accessToken->getRefreshToken());
343        $this->session->set('oauth2_expires_at', $expirationTime);
344        $this->session->set('oauth2_enable_password', $config['enable_password']);
345        $this->session->set('oauth2_allow_registration', $config['allow_registration']);
346
347        return $this->redirect->to('/register');
348    }
349}