Commit | Line | Data |
---|---|---|
ac20bc0b | 1 | <?php |
a9229942 | 2 | |
ac20bc0b | 3 | namespace wcf\action; |
a9229942 | 4 | |
ac20bc0b TD |
5 | use GuzzleHttp\ClientInterface; |
6 | use GuzzleHttp\Psr7\Request; | |
0c81e95b | 7 | use Laminas\Diactoros\Response\RedirectResponse; |
6ffbd987 | 8 | use Laminas\Diactoros\Uri; |
75009153 | 9 | use ParagonIE\ConstantTime\Base64UrlSafe; |
ac20bc0b | 10 | use ParagonIE\ConstantTime\Hex; |
85176ea5 | 11 | use Psr\Http\Client\ClientExceptionInterface; |
404be3eb | 12 | use Psr\Http\Message\ResponseInterface; |
ac20bc0b | 13 | use wcf\system\exception\NamedUserException; |
c123e15b | 14 | use wcf\system\exception\PermissionDeniedException; |
ac20bc0b | 15 | use wcf\system\io\HttpFactory; |
75f4f2a3 | 16 | use wcf\system\user\authentication\oauth\exception\StateValidationException; |
ac20bc0b TD |
17 | use wcf\system\user\authentication\oauth\User as OauthUser; |
18 | use wcf\system\WCF; | |
ac20bc0b TD |
19 | use 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 |
30 | abstract 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 | } |