Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
72.26% |
99 / 137 |
|
44.44% |
4 / 9 |
CRAP | |
0.00% |
0 / 1 |
Authorize | |
72.26% |
99 / 137 |
|
44.44% |
4 / 9 |
36.29 | |
0.00% |
0 / 1 |
execute | |
62.07% |
18 / 29 |
|
0.00% |
0 / 1 |
15.46 | |||
setValidScopes | |
50.00% |
7 / 14 |
|
0.00% |
0 / 1 |
2.50 | |||
getParamSettings | |
100.00% |
52 / 52 |
|
100.00% |
1 / 1 |
1 | |||
getApprovalRedirectResponse | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
getLoginRedirectResponse | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getGrantType | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getGrantClass | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 | |||
checkApproval | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
20 | |||
getFlatScopes | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\OAuth\Rest\Handler; |
4 | |
5 | use Exception; |
6 | use GuzzleHttp\Psr7\ServerRequest; |
7 | use League\OAuth2\Server\Entities\ScopeEntityInterface; |
8 | use League\OAuth2\Server\Exception\OAuthServerException; |
9 | use League\OAuth2\Server\RequestTypes\AuthorizationRequest; |
10 | use MediaWiki\Extension\OAuth\AuthorizationProvider\Grant\AuthorizationCodeAuthorization; |
11 | use MediaWiki\Extension\OAuth\Entity\ClientEntity; |
12 | use MediaWiki\Extension\OAuth\Entity\UserEntity; |
13 | use MediaWiki\Extension\OAuth\Exception\ClientApprovalDenyException; |
14 | use MediaWiki\Extension\OAuth\Response; |
15 | use MediaWiki\Rest\Response as RestResponse; |
16 | use MediaWiki\SpecialPage\SpecialPage; |
17 | use MediaWiki\User\User; |
18 | use MediaWiki\WikiMap\WikiMap; |
19 | use MWExceptionHandler; |
20 | use Throwable; |
21 | use Wikimedia\ParamValidator\ParamValidator; |
22 | |
23 | /** |
24 | * Handles the oauth2/authorize endpoint, which displays an authorization dialog to the user if |
25 | * needed (by redirecting to Special:OAuth/approve), and returns an authorization code that can be |
26 | * traded for the access token. |
27 | */ |
28 | class Authorize extends AuthenticationHandler { |
29 | private const RESPONSE_TYPE_CODE = 'code'; |
30 | |
31 | /** |
32 | * @inheritDoc |
33 | */ |
34 | public function execute() { |
35 | $response = new Response(); |
36 | |
37 | try { |
38 | if ( $this->queuedError ) { |
39 | throw $this->queuedError; |
40 | } |
41 | $request = ServerRequest::fromGlobals()->withQueryParams( |
42 | $this->getValidatedParams() |
43 | ); |
44 | // Note: Owner-only clients can only use client_credentials grant |
45 | // so would be rejected from this endpoint with invalid_client error |
46 | // automatically, no need for additional checks |
47 | if ( !$this->user instanceof User || !$this->user->isNamed() ) { |
48 | return $this->getLoginRedirectResponse(); |
49 | } |
50 | |
51 | $authProvider = $this->getAuthorizationProvider(); |
52 | $authProvider->setUser( $this->user ); |
53 | /** @var AuthorizationRequest $authRequest */ |
54 | $authRequest = $authProvider->init( $request ); |
55 | $this->setValidScopes( $authRequest ); |
56 | if ( !$authProvider->needsUserApproval() ) { |
57 | return $authProvider->authorize( $authRequest, $response ); |
58 | } |
59 | |
60 | if ( $this->getValidatedParams()['approval_cancel'] ) { |
61 | throw new ClientApprovalDenyException( $authRequest->getRedirectUri() ); |
62 | } |
63 | |
64 | if ( |
65 | $this->getValidatedParams()['approval_pass'] && |
66 | $this->checkApproval( $authRequest ) |
67 | ) { |
68 | $authRequest->setAuthorizationApproved( true ); |
69 | return $authProvider->authorize( $authRequest, $response ); |
70 | } |
71 | |
72 | return $this->getApprovalRedirectResponse( $authRequest ); |
73 | } catch ( OAuthServerException $ex ) { |
74 | return $this->errorResponse( $ex, $response ); |
75 | } catch ( Throwable $ex ) { |
76 | MWExceptionHandler::logException( $ex ); |
77 | return $this->errorResponse( |
78 | OAuthServerException::serverError( $ex->getMessage() ), |
79 | $response |
80 | ); |
81 | } |
82 | } |
83 | |
84 | protected function setValidScopes( AuthorizationRequest &$authRequest ) { |
85 | /** @var ClientEntity $client */ |
86 | $client = $authRequest->getClient(); |
87 | '@phan-var ClientEntity $client'; |
88 | |
89 | $scopes = $this->getValidatedParams()['scope']; |
90 | if ( !$scopes ) { |
91 | // No scope parameter |
92 | $authRequest->setScopes( |
93 | $client->getScopes() |
94 | ); |
95 | return; |
96 | } |
97 | // Trim off any not allowed scopes |
98 | $allowedScopes = $client->getGrants(); |
99 | |
100 | $authRequest->setScopes( array_filter( |
101 | $authRequest->getScopes(), |
102 | static function ( ScopeEntityInterface $scope ) use ( $allowedScopes ) { |
103 | return in_array( $scope->getIdentifier(), $allowedScopes ); |
104 | } |
105 | ) ); |
106 | } |
107 | |
108 | /** |
109 | * @inheritDoc |
110 | */ |
111 | public function getParamSettings() { |
112 | return [ |
113 | 'response_type' => [ |
114 | self::PARAM_SOURCE => 'query', |
115 | ParamValidator::PARAM_TYPE => [ |
116 | self::RESPONSE_TYPE_CODE |
117 | ], |
118 | ParamValidator::PARAM_REQUIRED => true, |
119 | ], |
120 | 'client_id' => [ |
121 | self::PARAM_SOURCE => 'query', |
122 | ParamValidator::PARAM_TYPE => 'string', |
123 | ParamValidator::PARAM_REQUIRED => true, |
124 | ], |
125 | 'redirect_uri' => [ |
126 | self::PARAM_SOURCE => 'query', |
127 | ParamValidator::PARAM_TYPE => 'string', |
128 | ParamValidator::PARAM_REQUIRED => false, |
129 | ], |
130 | 'scope' => [ |
131 | self::PARAM_SOURCE => 'query', |
132 | ParamValidator::PARAM_TYPE => 'string', |
133 | ParamValidator::PARAM_REQUIRED => false, |
134 | ], |
135 | 'state' => [ |
136 | self::PARAM_SOURCE => 'query', |
137 | ParamValidator::PARAM_TYPE => 'string', |
138 | ParamValidator::PARAM_REQUIRED => false, |
139 | ], |
140 | 'code_challenge' => [ |
141 | self::PARAM_SOURCE => 'query', |
142 | ParamValidator::PARAM_TYPE => 'string', |
143 | ParamValidator::PARAM_REQUIRED => false, |
144 | ], |
145 | 'code_challenge_method' => [ |
146 | self::PARAM_SOURCE => 'query', |
147 | ParamValidator::PARAM_TYPE => [ |
148 | 'plain', |
149 | 'S256' |
150 | ], |
151 | ParamValidator::PARAM_REQUIRED => false, |
152 | ], |
153 | 'approval_cancel' => [ |
154 | self::PARAM_SOURCE => 'query', |
155 | ParamValidator::PARAM_TYPE => 'string', |
156 | ParamValidator::PARAM_REQUIRED => false, |
157 | ], |
158 | 'approval_pass' => [ |
159 | self::PARAM_SOURCE => 'query', |
160 | ParamValidator::PARAM_TYPE => 'string', |
161 | ParamValidator::PARAM_REQUIRED => false, |
162 | ] |
163 | ]; |
164 | } |
165 | |
166 | /** |
167 | * @param AuthorizationRequest $authRequest |
168 | * @return RestResponse |
169 | */ |
170 | private function getApprovalRedirectResponse( AuthorizationRequest $authRequest ) { |
171 | return $this->getResponseFactory()->createTemporaryRedirect( |
172 | SpecialPage::getTitleFor( 'OAuth', 'approve' )->getFullURL( [ |
173 | 'returnto' => $this->getRequest()->getUri()->getPath(), |
174 | 'returntoquery' => $this->getQueryParamsCgi(), |
175 | 'client_id' => $authRequest->getClient()->getIdentifier(), |
176 | 'oauth_version' => ClientEntity::OAUTH_VERSION_2, |
177 | 'scope' => implode( ' ', array_map( static function ( ScopeEntityInterface $scope ) { |
178 | return $scope->getIdentifier(); |
179 | }, $authRequest->getScopes() ) ) |
180 | ] ) |
181 | ); |
182 | } |
183 | |
184 | private function getLoginRedirectResponse() { |
185 | return $this->getResponseFactory()->createTemporaryRedirect( |
186 | SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [ |
187 | 'returnto' => SpecialPage::getTitleFor( 'OAuth', 'rest_redirect' ), |
188 | 'returntoquery' => wfArrayToCgi( [ |
189 | 'rest_url' => $this->getRequest()->getUri()->__toString(), |
190 | ] ), |
191 | ] ) |
192 | ); |
193 | } |
194 | |
195 | /** |
196 | * @return string |
197 | */ |
198 | protected function getGrantType() { |
199 | return $this->getValidatedParams()['response_type']; |
200 | } |
201 | |
202 | /** |
203 | * @param string $grantType |
204 | * @return string|false |
205 | */ |
206 | protected function getGrantClass( $grantType ) { |
207 | switch ( $grantType ) { |
208 | case static::RESPONSE_TYPE_CODE: |
209 | return AuthorizationCodeAuthorization::class; |
210 | default: |
211 | return false; |
212 | } |
213 | } |
214 | |
215 | /** |
216 | * Check if user has approved the client, and scopes it requested |
217 | * |
218 | * @param AuthorizationRequest $authRequest |
219 | * @return bool |
220 | */ |
221 | private function checkApproval( AuthorizationRequest $authRequest ) { |
222 | /** @var ClientEntity $client */ |
223 | $client = $authRequest->getClient(); |
224 | '@phan-var ClientEntity $client'; |
225 | |
226 | /** @var UserEntity $userEntity */ |
227 | $userEntity = $authRequest->getUser(); |
228 | '@phan-var UserEntity $userEntity'; |
229 | |
230 | try { |
231 | $approval = $client->getCurrentAuthorization( |
232 | $userEntity->getMwUser(), |
233 | WikiMap::getCurrentWikiId() |
234 | ); |
235 | } catch ( Exception $ex ) { |
236 | return false; |
237 | } |
238 | |
239 | if ( !$approval ) { |
240 | return false; |
241 | } |
242 | |
243 | // Scopes in OAuth 1.0 are called grants |
244 | $scopes = $approval->getGrants(); |
245 | $requestedScopes = $this->getFlatScopes( $authRequest->getScopes() ); |
246 | $missing = array_diff( $requestedScopes, $scopes ); |
247 | if ( $missing ) { |
248 | return false; |
249 | } |
250 | |
251 | return true; |
252 | } |
253 | |
254 | /** |
255 | * @param ScopeEntityInterface[] $scopeEntities |
256 | * @return string[] |
257 | */ |
258 | private function getFlatScopes( $scopeEntities ) { |
259 | return array_map( static function ( ScopeEntityInterface $scope ) { |
260 | return $scope->getIdentifier(); |
261 | }, $scopeEntities ); |
262 | } |
263 | } |