Implementation of a new request handler for OAuth 2 requests
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / action / AbstractOauth2Action.class.php
CommitLineData
ac20bc0b 1<?php
a9229942 2
ac20bc0b 3namespace wcf\action;
a9229942 4
ac20bc0b
TD
5use GuzzleHttp\ClientInterface;
6use GuzzleHttp\Psr7\Request;
0c81e95b 7use Laminas\Diactoros\Response\RedirectResponse;
6ffbd987 8use Laminas\Diactoros\Uri;
75009153 9use ParagonIE\ConstantTime\Base64UrlSafe;
ac20bc0b 10use ParagonIE\ConstantTime\Hex;
85176ea5 11use Psr\Http\Client\ClientExceptionInterface;
404be3eb 12use Psr\Http\Message\ResponseInterface;
ac20bc0b 13use wcf\system\exception\NamedUserException;
c123e15b 14use wcf\system\exception\PermissionDeniedException;
ac20bc0b 15use wcf\system\io\HttpFactory;
75f4f2a3 16use wcf\system\user\authentication\oauth\exception\StateValidationException;
ac20bc0b
TD
17use wcf\system\user\authentication\oauth\User as OauthUser;
18use wcf\system\WCF;
ac20bc0b
TD
19use wcf\util\JSON;
20
21/**
22 * Generic implementation to handle the OAuth 2 flow.
a9229942
TD
23 *
24 * @author Tim Duesterhus
0c81e95b 25 * @copyright 2001-2022 WoltLab GmbH
a9229942 26 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
9df97de0 27 * @since 5.4
34de730b 28 * @deprecated 6.1 use `AbstractOauth2AuthAction` instead
ac20bc0b 29 */
a9229942
TD
30abstract class AbstractOauth2Action extends AbstractAction
31{
32 private const STATE = self::class . "\0state_parameter";
33
75009153
TD
34 private const PKCE = self::class . "\0pkce";
35
70651adf 36 private ClientInterface $httpClient;
a9229942
TD
37
38 /**
39 * @inheritDoc
40 */
41 public function readParameters()
42 {
43 parent::readParameters();
44
e2542b4c 45 if (WCF::getSession()->spiderIdentifier) {
a9229942
TD
46 throw new PermissionDeniedException();
47 }
48 }
49
50 /**
51 * Returns a "static" instance of the HTTP client to use to allow
52 * for TCP connection reuse.
53 */
26255314 54 protected function getHttpClient(): ClientInterface
a9229942 55 {
70651adf 56 if (!isset($this->httpClient)) {
5a5c975b 57 $this->httpClient = HttpFactory::makeClientWithTimeout(5);
a9229942
TD
58 }
59
60 return $this->httpClient;
61 }
62
63 /**
64 * Returns the URL of the '/token' endpoint that turns the code into an access token.
65 */
66 abstract protected function getTokenEndpoint(): string;
67
68 /**
69 * Returns the 'client_id'.
70 */
71 abstract protected function getClientId(): string;
72
73 /**
74 * Returns the 'client_secret'.
75 */
76 abstract protected function getClientSecret(): string;
77
78 /**
79 * Returns the 'scope' to request.
80 */
81 abstract protected function getScope(): string;
82
83 /**
84 * Returns the URL of the '/authorize' endpoint where the user is redirected to.
85 */
86 abstract protected function getAuthorizeUrl(): string;
87
88 /**
89 * Returns the callback URL. This should most likely be:
90 *
91 * LinkHandler::getInstance()->getControllerLink(self::class)
92 */
93 abstract protected function getCallbackUrl(): string;
94
95 /**
96 * Whether to validate the state or not. Should be 'true' to protect
97 * against CSRF attacks.
98 */
99 abstract protected function supportsState(): bool;
100
75009153
TD
101 /**
102 * Whether to use PKCE (RFC 7636). Defaults to 'false'.
103 */
104 protected function usePkce(): bool
105 {
106 return false;
107 }
108
a9229942
TD
109 /**
110 * Turns the access token response into an oauth user.
111 */
112 abstract protected function getUser(array $accessToken): OauthUser;
113
114 /**
115 * Processes the user (e.g. by registering session variables and redirecting somewhere).
116 */
f6c08ea7 117 abstract protected function processUser(OauthUser $oauthUser): ResponseInterface;
a9229942
TD
118
119 /**
120 * Validates the state parameter.
121 */
122 protected function validateState()
123 {
1d74b140
TD
124 try {
125 if (!isset($_GET['state'])) {
126 throw new StateValidationException('Missing state parameter');
127 }
128 if (!($sessionState = WCF::getSession()->getVar(self::STATE))) {
129 throw new StateValidationException('Missing state in session');
130 }
131 if (!\hash_equals($sessionState, (string)$_GET['state'])) {
132 throw new StateValidationException('Mismatching state');
133 }
134 } finally {
135 WCF::getSession()->unregister(self::STATE);
a9229942 136 }
a9229942
TD
137 }
138
139 /**
140 * Turns the 'code' into an access token.
141 */
142 protected function codeToAccessToken(string $code): array
143 {
75009153 144 $payload = [
a9229942
TD
145 'grant_type' => 'authorization_code',
146 'client_id' => $this->getClientId(),
147 'client_secret' => $this->getClientSecret(),
148 'redirect_uri' => $this->getCallbackUrl(),
f3f52804 149 'code' => $code,
75009153
TD
150 ];
151
152 if ($this->usePkce()) {
153 if (!($verifier = WCF::getSession()->getVar(self::PKCE))) {
154 throw new StateValidationException('Missing PKCE verifier in session');
155 }
156
157 $payload['code_verifier'] = $verifier;
158 }
159
160 $request = new Request('POST', $this->getTokenEndpoint(), [
161 'Accept' => 'application/json',
162 'Content-Type' => 'application/x-www-form-urlencoded',
163 ], \http_build_query($payload, '', '&', \PHP_QUERY_RFC1738));
a9229942
TD
164
165 try {
166 $response = $this->getHttpClient()->send($request);
167 } finally {
168 // Validate state. Validation of state is executed after fetching the
169 // access_token to invalidate 'code'.
170 //
171 // Validation is happening within the `finally` so that the StateValidationException
85176ea5 172 // overwrites any HTTP exception (improving the error message).
a9229942
TD
173 if ($this->supportsState()) {
174 $this->validateState();
175 }
176 }
177
178 $parsed = JSON::decode((string)$response->getBody());
179
180 if (!empty($parsed['error'])) {
181 throw new \Exception(\sprintf(
182 "Access token response indicates an error: '%s'",
183 $parsed['error']
184 ));
185 }
186
187 if (empty($parsed['access_token'])) {
188 throw new \Exception("Access token response does not have the 'access_token' key.");
189 }
190
191 return $parsed;
192 }
193
f6c08ea7 194 protected function handleError(string $error): ResponseInterface
a9229942
TD
195 {
196 throw new NamedUserException(WCF::getLanguage()->getDynamicVariable('wcf.user.3rdparty.login.error.' . $error));
197 }
198
199 /**
200 * Initiates the OAuth flow by redirecting to the '/authorize' URL.
201 */
f6c08ea7 202 protected function initiate(): ResponseInterface
a9229942
TD
203 {
204 $parameters = [
205 'response_type' => 'code',
206 'client_id' => $this->getClientId(),
207 'scope' => $this->getScope(),
208 'redirect_uri' => $this->getCallbackUrl(),
209 ];
210
211 if ($this->supportsState()) {
212 $token = Hex::encode(\random_bytes(16));
213 WCF::getSession()->register(self::STATE, $token);
214
215 $parameters['state'] = $token;
216 }
217
75009153
TD
218 if ($this->usePkce()) {
219 $verifier = Hex::encode(\random_bytes(32));
220 WCF::getSession()->register(self::PKCE, $verifier);
221
222 $parameters['code_challenge'] = Base64UrlSafe::encodeUnpadded(\hash('sha256', $verifier, true));
223 $parameters['code_challenge_method'] = 'S256';
224 }
225
6ffbd987
TD
226 $encodedParameters = \http_build_query($parameters, '', '&');
227
228 $url = new Uri($this->getAuthorizeUrl());
99ae74c4
TD
229 $query = $url->getQuery();
230 if ($query !== '') {
6ffbd987
TD
231 $url = $url->withQuery("{$query}&{$encodedParameters}");
232 } else {
233 $url = $url->withQuery($encodedParameters);
234 }
a9229942 235
0c81e95b 236 return new RedirectResponse($url);
a9229942
TD
237 }
238
239 /**
240 * @inheritDoc
241 */
242 public function execute()
243 {
244 parent::execute();
245
246 try {
247 if (isset($_GET['code'])) {
248 $accessToken = $this->codeToAccessToken($_GET['code']);
249 $oauthUser = $this->getUser($accessToken);
250
f6c08ea7 251 return $this->processUser($oauthUser);
a9229942 252 } elseif (isset($_GET['error'])) {
f6c08ea7 253 return $this->handleError($_GET['error']);
a9229942 254 } else {
f6c08ea7 255 return $this->initiate();
a9229942
TD
256 }
257 } catch (NamedUserException | PermissionDeniedException $e) {
258 throw $e;
259 } catch (StateValidationException $e) {
260 throw new NamedUserException(WCF::getLanguage()->getDynamicVariable(
261 'wcf.user.3rdparty.login.error.stateValidation'
262 ));
263 } catch (\Exception $e) {
264 $exceptionID = \wcf\functions\exception\logThrowable($e);
265
266 $type = 'genericException';
85176ea5 267 if ($e instanceof ClientExceptionInterface) {
a9229942
TD
268 $type = 'httpError';
269 }
270
271 throw new NamedUserException(WCF::getLanguage()->getDynamicVariable(
272 'wcf.user.3rdparty.login.error.' . $type,
273 [
274 'exceptionID' => $exceptionID,
275 ]
276 ));
277 }
278 }
ac20bc0b 279}