Implementation of a new request handler for OAuth 2 requests
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / action / AbstractOauth2AuthAction.class.php
CommitLineData
34de730b
C
1<?php
2
3namespace wcf\action;
4
5use CuyZ\Valinor\MapperBuilder;
6use GuzzleHttp\ClientInterface;
7use GuzzleHttp\Psr7\Request;
8use Laminas\Diactoros\Response\RedirectResponse;
9use Laminas\Diactoros\Uri;
10use ParagonIE\ConstantTime\Base64UrlSafe;
11use ParagonIE\ConstantTime\Hex;
12use Psr\Http\Client\ClientExceptionInterface;
13use Psr\Http\Message\ResponseInterface;
14use Psr\Http\Message\ServerRequestInterface;
15use Psr\Http\Server\RequestHandlerInterface;
16use wcf\data\user\User;
17use wcf\form\AccountManagementForm;
18use wcf\form\RegisterForm;
19use wcf\system\event\EventHandler;
20use wcf\system\exception\IllegalLinkException;
21use wcf\system\exception\NamedUserException;
22use wcf\system\exception\PermissionDeniedException;
23use wcf\system\io\HttpFactory;
24use wcf\system\request\LinkHandler;
25use wcf\system\user\authentication\event\UserLoggedIn;
26use wcf\system\user\authentication\LoginRedirect;
27use wcf\system\user\authentication\oauth\exception\StateValidationException;
28use wcf\system\user\authentication\oauth\Failure as OAuth2Failure;
29use wcf\system\user\authentication\oauth\Success as OAuth2Success;
30use wcf\system\user\authentication\oauth\User as OauthUser;
31use wcf\system\WCF;
32use wcf\util\JSON;
33
34/**
35 * Generic implementation to handle the OAuth 2 flow.
36 *
37 * @author Olaf Braun
38 * @copyright 2001-2024 WoltLab GmbH
39 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
40 * @since 6.1
41 */
42abstract class AbstractOauth2AuthAction implements RequestHandlerInterface
43{
44 private const STATE = self::class . "\0state_parameter";
45
46 private const PKCE = self::class . "\0pkce";
47
48 private ClientInterface $httpClient;
49
50 #[\Override]
51 public function handle(ServerRequestInterface $request): ResponseInterface
52 {
53 if (!$this->isEnabled()) {
54 throw new IllegalLinkException();
55 }
56 if (WCF::getSession()->spiderIdentifier) {
57 throw new PermissionDeniedException();
58 }
59
60 $parameters = $this->mapParameters($request);
61
62 try {
63 if ($parameters instanceof OAuth2Success) {
64 $accessToken = $this->getAccessToken($parameters);
65 $user = $this->getUser($accessToken);
66
67 return $this->processUser($user);
68 } elseif ($parameters instanceof OAuth2Failure) {
69 return $this->handleError($parameters);
70 } else {
71 return $this->initiate();
72 }
73 } catch (NamedUserException $e) {
74 throw $e;
75 } catch (StateValidationException $e) {
76 throw new NamedUserException(
77 WCF::getLanguage()->getDynamicVariable(
78 'wcf.user.3rdparty.login.error.stateValidation'
79 )
80 );
81 } catch (\Exception $e) {
82 $exceptionID = \wcf\functions\exception\logThrowable($e);
83
84 $type = 'genericException';
85 if ($e instanceof ClientExceptionInterface) {
86 $type = 'httpError';
87 }
88
89 throw new NamedUserException(
90 WCF::getLanguage()->getDynamicVariable(
91 'wcf.user.3rdparty.login.error.' . $type,
92 [
93 'exceptionID' => $exceptionID,
94 ]
95 )
96 );
97 }
98 }
99
100 /**
101 * Returns whether this OAuth provider is enabled.
102 */
103 abstract protected function isEnabled(): bool;
104
105 protected function mapParameters(ServerRequestInterface $request): OAuth2Success | OAuth2Failure | null
106 {
107 try {
108 $mapper = (new MapperBuilder())
109 ->allowSuperfluousKeys()
110 ->enableFlexibleCasting()
111 ->mapper();
112
113 return $mapper->map(
114 \sprintf("%s|%s", OAuth2Success::class, OAuth2Failure::class),
115 $request->getQueryParams()
116 );
117 } catch (\Throwable) {
118 return null;
119 }
120 }
121
122 /**
123 * Turns the 'code' into an access token.
124 */
125 protected function getAccessToken(OAuth2Success $auth2Success): array
126 {
127 $payload = [
128 'grant_type' => 'authorization_code',
129 'client_id' => $this->getClientId(),
130 'client_secret' => $this->getClientSecret(),
131 'redirect_uri' => $this->getCallbackUrl(),
132 'code' => $auth2Success->code,
133 ];
134
135 if ($this->usePkce()) {
136 if (!($verifier = WCF::getSession()->getVar(self::PKCE))) {
137 throw new StateValidationException('Missing PKCE verifier in session');
138 }
139
140 $payload['code_verifier'] = $verifier;
141 }
142
143 $request = new Request('POST', $this->getTokenEndpoint(), [
144 'Accept' => 'application/json',
145 'Content-Type' => 'application/x-www-form-urlencoded',
146 ], \http_build_query($payload, '', '&', \PHP_QUERY_RFC1738));
147
148 try {
149 $response = $this->getHttpClient()->send($request);
150 } finally {
151 // Validate state. Validation of state is executed after fetching the
152 // access_token to invalidate 'code'.
153 //
154 // Validation is happening within the `finally` so that the StateValidationException
155 // overwrites any HTTP exception (improving the error message).
156 if ($this->supportsState()) {
157 $this->validateState($auth2Success);
158 }
159 }
160
161 $parsed = JSON::decode((string)$response->getBody());
162
163 if (!empty($parsed['error'])) {
164 throw new \Exception(
165 \sprintf(
166 "Access token response indicates an error: '%s'",
167 $parsed['error']
168 )
169 );
170 }
171
172 if (empty($parsed['access_token'])) {
173 throw new \Exception("Access token response does not have the 'access_token' key.");
174 }
175
176 return $parsed;
177 }
178
179 /**
180 * Returns the 'client_id'.
181 */
182 abstract protected function getClientId(): string;
183
184 /**
185 * Returns the 'client_secret'.
186 */
187 abstract protected function getClientSecret(): string;
188
189 /**
190 * Returns the callback URL. This should most likely be:
191 *
192 * LinkHandler::getInstance()->getControllerLink(self::class)
193 */
194 abstract protected function getCallbackUrl(): string;
195
196 /**
197 * Whether to use PKCE (RFC 7636). Defaults to 'false'.
198 */
199 protected function usePkce(): bool
200 {
201 return false;
202 }
203
204 /**
205 * Returns the URL of the '/token' endpoint that turns the code into an access token.
206 */
207 abstract protected function getTokenEndpoint(): string;
208
209 /**
210 * Returns a "static" instance of the HTTP client to use to allow
211 * for TCP connection reuse.
212 */
213 protected function getHttpClient(): ClientInterface
214 {
215 if (!isset($this->httpClient)) {
216 $this->httpClient = HttpFactory::makeClientWithTimeout(5);
217 }
218
219 return $this->httpClient;
220 }
221
222 /**
223 * Whether to validate the state or not. Should be 'true' to protect
224 * against CSRF attacks.
225 */
226 abstract protected function supportsState(): bool;
227
228 /**
229 * Validates the state parameter.
230 */
231 protected function validateState(OAuth2Success $auth2Success): void
232 {
233 try {
234 if (!($sessionState = WCF::getSession()->getVar(self::STATE))) {
235 throw new StateValidationException('Missing state in session');
236 }
237 if (!\hash_equals($sessionState, $auth2Success->state)) {
238 throw new StateValidationException('Mismatching state');
239 }
240 } finally {
241 WCF::getSession()->unregister(self::STATE);
242 }
243 }
244
245 /**
246 * Turns the access token response into an oauth user.
247 */
248 abstract protected function getUser(array $accessToken): OauthUser;
249
250 /**
251 * Processes the user (e.g. by registering session variables and redirecting somewhere).
252 */
253 protected function processUser(OauthUser $oauthUser): ResponseInterface
254 {
255 $user = $this->getInternalUser($oauthUser);
256
257 if ($user->userID) {
258 if (WCF::getUser()->userID) {
259 // This account belongs to an existing user, but we are already logged in.
260 // This can't be handled.
261
262 throw new NamedUserException($this->getInUseErrorMessage());
263 } else {
264 // This account belongs to an existing user, we are not logged in.
265 // Perform the login.
266
267 WCF::getSession()->changeUser($user);
268 WCF::getSession()->update();
269 EventHandler::getInstance()->fire(
270 new UserLoggedIn($user)
271 );
272
273 return new RedirectResponse(
274 LoginRedirect::getUrl()
275 );
276 }
277 } else {
278 WCF::getSession()->register('__3rdPartyProvider', $this->getProviderName());
279
280 if (WCF::getUser()->userID) {
281 // This account does not belong to anyone and we are already logged in.
282 // Thus we want to connect this account.
283
284 WCF::getSession()->register('__oauthUser', $oauthUser);
285
286 return new RedirectResponse(
287 LinkHandler::getInstance()->getControllerLink(
288 AccountManagementForm::class,
289 [],
290 '#3rdParty'
291 )
292 );
293 } else {
294 // This account does not belong to anyone and we are not logged in.
295 // Thus we want to connect this account to a newly registered user.
296 return $this->performeRegister($oauthUser);
297 }
298 }
299 }
300
301 /**
302 * Returns the user who is assigned to the OAuth user.
303 */
304 protected function getInternalUser(OauthUser $oauthUser): User
305 {
306 return User::getUserByAuthData(\sprintf("%s:%s", $this->getProviderName(), $oauthUser->getId()));
307 }
308
309 /**
310 * Returns the name of the provider.
311 */
312 abstract protected function getProviderName(): string;
313
314 /**
315 * Returns the error message if the user is logged in and the external account is linked to another user.
316 */
317 protected function getInUseErrorMessage(): string
318 {
319 return WCF::getLanguage()->getDynamicVariable(
320 "wcf.user.3rdparty.{$this->getProviderName()}.connect.error.inuse"
321 );
322 }
323
324 protected function performeRegister(OauthUser $oauthUser): ResponseInterface
325 {
326 WCF::getSession()->register('__oauthUser', $oauthUser);
327 WCF::getSession()->register('__username', $oauthUser->getUsername());
328 WCF::getSession()->register('__email', $oauthUser->getEmail());
329
330 // We assume that bots won't register an external account first, so
331 // we skip the captcha.
332 WCF::getSession()->register('noRegistrationCaptcha', true);
333
334 WCF::getSession()->update();
335
336 return new RedirectResponse(
337 LinkHandler::getInstance()->getControllerLink(RegisterForm::class)
338 );
339 }
340
341 protected function handleError(OAuth2Failure $oauth2Failure): ResponseInterface
342 {
343 throw new NamedUserException(
344 WCF::getLanguage()->getDynamicVariable('wcf.user.3rdparty.login.error.' . $oauth2Failure->error)
345 );
346 }
347
348 /**
349 * Initiates the OAuth flow by redirecting to the '/authorize' URL.
350 */
351 protected function initiate(): ResponseInterface
352 {
353 $parameters = [
354 'response_type' => 'code',
355 'client_id' => $this->getClientId(),
356 'scope' => $this->getScope(),
357 'redirect_uri' => $this->getCallbackUrl(),
358 ];
359
360 if ($this->supportsState()) {
361 $token = Hex::encode(\random_bytes(16));
362 WCF::getSession()->register(self::STATE, $token);
363
364 $parameters['state'] = $token;
365 }
366
367 if ($this->usePkce()) {
368 $verifier = Hex::encode(\random_bytes(32));
369 WCF::getSession()->register(self::PKCE, $verifier);
370
371 $parameters['code_challenge'] = Base64UrlSafe::encodeUnpadded(\hash('sha256', $verifier, true));
372 $parameters['code_challenge_method'] = 'S256';
373 }
374
375 $encodedParameters = \http_build_query($parameters, '', '&');
376
377 $url = new Uri($this->getAuthorizeUrl());
378 $query = $url->getQuery();
379 if ($query !== '') {
380 $url = $url->withQuery("{$query}&{$encodedParameters}");
381 } else {
382 $url = $url->withQuery($encodedParameters);
383 }
384
385 return new RedirectResponse($url);
386 }
387
388 /**
389 * Returns the 'scope' to request.
390 */
391 abstract protected function getScope(): string;
392
393 /**
394 * Returns the URL of the '/authorize' endpoint where the user is redirected to.
395 */
396 abstract protected function getAuthorizeUrl(): string;
397}