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