Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
179 / 179
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
OAuthController
100.00% covered (success)
100.00%
179 / 179
100.00% covered (success)
100.00%
11 / 11
37
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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 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%
16 / 16
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->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}