OAuth2Authenticator   A
last analyzed

Complexity

Total Complexity 16

Size/Duplication

Total Lines 172
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 64
c 2
b 0
f 0
dl 0
loc 172
rs 10
wmc 16

5 Methods

Rating   Name   Duplication   Size   Complexity  
A authenticate() 0 30 4
A redirectUri() 0 10 2
B providerConnect() 0 39 6
A __construct() 0 5 1
A initProvider() 0 13 3
1
<?php
2
declare(strict_types=1);
3
4
/**
5
 * BEdita, API-first content management framework
6
 * Copyright 2022 ChannelWeb Srl, Chialab Srl
7
 *
8
 * This file is part of BEdita: you can redistribute it and/or modify
9
 * it under the terms of the GNU Lesser General Public License as published
10
 * by the Free Software Foundation, either version 3 of the License, or
11
 * (at your option) any later version.
12
 *
13
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
14
 */
15
namespace BEdita\WebTools\Authenticator;
16
17
use Authentication\Authenticator\AbstractAuthenticator;
18
use Authentication\Authenticator\Result;
19
use Authentication\Authenticator\ResultInterface;
20
use Authentication\Identifier\IdentifierInterface;
21
use Cake\Http\Exception\BadRequestException;
22
use Cake\Log\LogTrait;
23
use Cake\Routing\Router;
24
use Cake\Utility\Hash;
25
use Firebase\JWT\JWT;
26
use League\OAuth2\Client\Provider\AbstractProvider;
27
use Psr\Http\Message\ServerRequestInterface;
28
29
/**
30
 * Authenticator class for the OAuth2 flow.
31
 * Provides a connection to the external OAuth2 provider and use
32
 * the identifier class to verify the credentials.
33
 */
34
class OAuth2Authenticator extends AbstractAuthenticator
35
{
36
    use LogTrait;
37
38
    /**
39
     * External Auth provider
40
     *
41
     * @var \League\OAuth2\Client\Provider\AbstractProvider|null
42
     */
43
    protected ?AbstractProvider $provider = null;
44
45
    /**
46
     * Authentication URL key
47
     *
48
     * @var string
49
     */
50
    public const AUTH_URL_KEY = 'authUrl';
51
52
    /**
53
     * Configuration options
54
     *
55
     * - `sessionKey` - Session key to store the request attribute which holds the identity.
56
     * - `redirect` - redirect URL in array format as named route,
57
     *                  used to redirect from the provider to the application
58
     * - `providers` - configured OAuth2 providers, see https://github.com/bedita/web-tools/wiki/OAuth2-providers-configurations
59
     * - `urlResolver` - callback to resolve redirect URL, defaults to ` Router::url($route, true)`
60
     *
61
     * @var array
62
     */
63
    protected array $_defaultConfig = [
64
        'sessionKey' => 'oauth2state',
65
        'redirect' => ['_name' => 'login'],
66
        'providers' => [],
67
        'urlResolver' => null,
68
    ];
69
70
    /**
71
     * Constructor
72
     *
73
     * @param \Authentication\Identifier\IdentifierInterface $identifier Identifier or identifiers collection.
74
     * @param array $config Configuration settings.
75
     */
76
    public function __construct(IdentifierInterface $identifier, array $config = [])
77
    {
78
        // Setup default URL resolver
79
        $this->setConfig('urlResolver', fn ($route) => Router::url($route, true));
80
        parent::__construct($identifier, $config);
81
    }
82
83
    /**
84
     * @inheritDoc
85
     */
86
    public function authenticate(ServerRequestInterface $request): ResultInterface
87
    {
88
        // extract provider from request
89
        $provider = basename($request->getUri()->getPath());
90
        // leeway is needed for clock skew
91
        $leeway = (int)$this->getConfig(sprintf('providers.%s.clientOptions.jwtLeeway', $provider), 0);
92
        if ($leeway) {
93
            JWT::$leeway = $leeway;
94
        }
95
96
        $connect = $this->providerConnect($provider, $request);
97
        if (!empty($connect[static::AUTH_URL_KEY])) {
98
            return new Result($connect, Result::SUCCESS);
99
        }
100
101
        $usernameField = (string)$this->getConfig(sprintf('providers.%s.map.provider_username', $provider));
102
        $data = [
103
            'auth_provider' => $provider,
104
            'provider_username' => (string)Hash::get($connect, sprintf('user.%s', $usernameField)),
105
            'access_token' => Hash::get($connect, 'token.access_token'),
106
            'provider_userdata' => (array)Hash::get($connect, 'user'),
107
            'id_token' => Hash::get($connect, 'token.id_token'),
108
        ];
109
        $user = $this->_identifier->identify($data);
110
111
        if (empty($user)) {
112
            return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND, $this->_identifier->getErrors());
113
        }
114
115
        return new Result($user, Result::SUCCESS);
116
    }
117
118
    /**
119
     * Perform Oauth2 connect action on Auth Provider.
120
     *
121
     * @param string $provider Provider name.
122
     * @param \Psr\Http\Message\ServerRequestInterface $request Request to get authentication information from.
123
     * @return array;
124
     * @throws \Cake\Http\Exception\BadRequestException
125
     */
126
    protected function providerConnect(string $provider, ServerRequestInterface $request): array
127
    {
128
        $this->initProvider($provider, $request);
129
130
        if ($request->getMethod() === 'GET') {
131
            $query = $request->getQueryParams();
132
        } else {
133
            $query = $request->getParsedBody();
134
        }
135
        $sessionKey = $this->getConfig('sessionKey');
136
        /** @var \Cake\Http\Session $session */
137
        $session = $request->getAttribute('session');
138
139
        if (!isset($query['code'])) {
140
            // If we don't have an authorization code then get one
141
            $options = (array)$this->getConfig(sprintf('providers.%s.options', $provider));
142
            $authUrl = $this->provider->getAuthorizationUrl($options);
143
            $session->write($sessionKey, $this->provider->getState());
144
145
            return [static::AUTH_URL_KEY => $authUrl];
146
        }
147
148
        // Check given state against previously stored one to mitigate CSRF attack
149
        if (
150
            (empty($query['state']) || $query['state'] !== $session->read($sessionKey))
151
            && $request->getMethod() === 'GET'
152
        ) {
153
            $session->delete($sessionKey);
154
            throw new BadRequestException('Invalid state');
155
        }
156
157
        // Try to get an access token (using the authorization code grant)
158
        /** @var \League\OAuth2\Client\Token\AccessToken $token */
159
        $token = $this->provider->getAccessToken('authorization_code', ['code' => $query['code']]);
160
        // We got an access token, let's now get the user's details
161
        $user = $this->provider->getResourceOwner($token)->toArray();
162
        $token = $token->jsonSerialize();
163
164
        return compact('token', 'user');
165
    }
166
167
    /**
168
     * Init external auth provider via configuration
169
     *
170
     * @param string $provider Provider name.
171
     * @param \Psr\Http\Message\ServerRequestInterface $request Request to get authentication information from.
172
     * @return void
173
     */
174
    protected function initProvider(string $provider, ServerRequestInterface $request): void
175
    {
176
        $providerConf = (array)$this->getConfig(sprintf('providers.%s', $provider));
177
        if (empty($providerConf['class']) || empty($providerConf['setup'])) {
178
            throw new BadRequestException('Invalid auth provider ' . $provider);
179
        }
180
181
        $redirectUri = $this->redirectUri($provider, $request);
182
        $this->log(sprintf('Creating %s provider with redirect url %s', $provider, $redirectUri), 'info');
183
        $setup = (array)Hash::get($providerConf, 'setup') + compact('redirectUri');
184
185
        $class = Hash::get($providerConf, 'class');
186
        $this->provider = new $class($setup);
187
    }
188
189
    /**
190
     * Build redirect URL from request and provider information.
191
     *
192
     * @param string $provider Provider name.
193
     * @param \Psr\Http\Message\ServerRequestInterface $request Request to get authentication information from.
194
     * @return string
195
     */
196
    protected function redirectUri(string $provider, ServerRequestInterface $request): string
197
    {
198
        $redirectUri = (array)$this->getConfig('redirect') + compact('provider');
199
        $query = $request->getQueryParams();
200
        $queryRedirectUrl = Hash::get($query, 'redirect');
201
        if (!empty($queryRedirectUrl)) {
202
            $redirectUri['?'] = ['redirect' => $queryRedirectUrl];
203
        }
204
205
        return call_user_func($this->getConfig('urlResolver'), $redirectUri);
206
    }
207
}
208