1 | <?php |
||
2 | |||
3 | declare(strict_types=1); |
||
4 | |||
5 | namespace Yiisoft\Yii\AuthClient; |
||
6 | |||
7 | use Psr\Http\Message\ServerRequestInterface; |
||
8 | use Yiisoft\Strings\Inflector; |
||
9 | use Yiisoft\Strings\StringHelper; |
||
10 | |||
11 | /** |
||
12 | * OpenId provides a simple interface for OpenID (1.1 and 2.0) authentication. |
||
13 | * Supports Yadis and HTML discovery. |
||
14 | * |
||
15 | * Usage: |
||
16 | * |
||
17 | * ```php |
||
18 | * use Yiisoft\Yii\AuthClient\OpenId; |
||
19 | * ... |
||
20 | * $client = new OpenId(); |
||
21 | * $client->setAuthUrl = 'https://open.id.provider.url'; // Setup provider endpoint |
||
22 | * $url = $client->buildAuthUrl(); // Get authentication URL |
||
23 | * return $this->responseFactory->createResponse(302)->withHeader('Location', $url); // Redirect to authentication URL |
||
24 | * // After user returns at our site: |
||
25 | * if ($client->validate()) { // validate response |
||
26 | * $userAttributes = $client->getUserAttributes(); // get account info |
||
27 | * ... |
||
28 | * } |
||
29 | * ``` |
||
30 | * |
||
31 | * AX and SREG extensions are supported. |
||
32 | * To use them, specify {@see requiredAttributes} and/or {@see optionalAttributes}. |
||
33 | * |
||
34 | * @link https://openid.net/ |
||
35 | */ |
||
36 | abstract class OpenId extends AuthClient |
||
37 | { |
||
38 | /** |
||
39 | * @var string authentication base URL, which should be used to compose actual authentication URL |
||
40 | * by {@see buildAuthUrl()} method. |
||
41 | */ |
||
42 | protected string $authUrl; |
||
43 | /** |
||
44 | * @var array list of attributes, which always should be returned from server. |
||
45 | * Attribute names should be always specified in AX format. |
||
46 | * For example: |
||
47 | * |
||
48 | * ```php |
||
49 | * ['namePerson/friendly', 'contact/email'] |
||
50 | * ``` |
||
51 | */ |
||
52 | private array $requiredAttributes = []; |
||
53 | /** |
||
54 | * @var array list of attributes, which could be returned from server. |
||
55 | * Attribute names should be always specified in AX format. |
||
56 | * For example: |
||
57 | * |
||
58 | * ```php |
||
59 | * ['namePerson/first', 'namePerson/last'] |
||
60 | * ``` |
||
61 | */ |
||
62 | private array $optionalAttributes = []; |
||
63 | /** |
||
64 | * @var bool whether to verify the peer's certificate. |
||
65 | */ |
||
66 | private bool $verifyPeer; |
||
67 | /** |
||
68 | * @var string directory that holds multiple CA certificates. |
||
69 | * This value will take effect only if [[verifyPeer]] is set. |
||
70 | */ |
||
71 | private string $capath; |
||
72 | /** |
||
73 | * @var string the name of a file holding one or more certificates to verify the peer with. |
||
74 | * This value will take effect only if [[verifyPeer]] is set. |
||
75 | */ |
||
76 | private string $cainfo; |
||
77 | /** |
||
78 | * @var array data, which should be used to retrieve the OpenID response. |
||
79 | * If not set combination of GET and POST will be used. |
||
80 | */ |
||
81 | private array $data; |
||
82 | /** |
||
83 | * @var array map of matches between AX and SREG attribute names in format: axAttributeName => sregAttributeName |
||
84 | */ |
||
85 | private array $axToSregMap = [ |
||
86 | 'namePerson/friendly' => 'nickname', |
||
87 | 'contact/email' => 'email', |
||
88 | 'namePerson' => 'fullname', |
||
89 | 'birthDate' => 'dob', |
||
90 | 'person/gender' => 'gender', |
||
91 | 'contact/postalCode/home' => 'postcode', |
||
92 | 'contact/country/home' => 'country', |
||
93 | 'pref/language' => 'language', |
||
94 | 'pref/timezone' => 'timezone', |
||
95 | ]; |
||
96 | |||
97 | /** |
||
98 | * @var string authentication return URL. |
||
99 | */ |
||
100 | private string $returnUrl; |
||
101 | /** |
||
102 | * @var string claimed identifier (identity) |
||
103 | */ |
||
104 | private string $claimedId; |
||
105 | /** |
||
106 | * @var string client trust root (realm), by default [[\yii\web\Request::hostInfo]] value will be used. |
||
107 | */ |
||
108 | private string $trustRoot; |
||
109 | |||
110 | /** |
||
111 | * Returns authentication URL. Usually, you want to redirect your user to it. |
||
112 | * |
||
113 | * @param ServerRequestInterface $incomingRequest |
||
114 | * @param bool $identifierSelect whether to request OP to select identity for an user in OpenID 2, does not affect OpenID 1. |
||
115 | * @param array $params |
||
116 | * |
||
117 | * @return string the authentication URL. |
||
118 | */ |
||
119 | public function buildAuthUrl(ServerRequestInterface $incomingRequest, array $params = []): string |
||
120 | { |
||
121 | $authUrl = $this->authUrl; |
||
122 | $claimedId = $this->getClaimedId(); |
||
123 | if (empty($claimedId)) { |
||
124 | $this->setClaimedId($authUrl); |
||
125 | } |
||
126 | $serverInfo = $this->discover($authUrl); |
||
127 | if ($serverInfo['version'] === 2) { |
||
128 | if (isset($params['identifierSelect'])) { |
||
129 | $serverInfo['identifier_select'] = $params['identifierSelect']; |
||
130 | } |
||
131 | |||
132 | return $this->buildAuthUrlV2($incomingRequest, $serverInfo); |
||
133 | } |
||
134 | |||
135 | return $this->buildAuthUrlV1($incomingRequest, $serverInfo); |
||
136 | } |
||
137 | |||
138 | /** |
||
139 | * @return string claimed identifier (identity). |
||
140 | */ |
||
141 | public function getClaimedId(): string |
||
142 | { |
||
143 | if ($this->claimedId === null) { |
||
144 | if (isset($this->data['openid_claimed_id'])) { |
||
145 | $this->claimedId = $this->data['openid_claimed_id']; |
||
146 | } elseif (isset($this->data['openid_identity'])) { |
||
147 | $this->claimedId = $this->data['openid_identity']; |
||
148 | } |
||
149 | } |
||
150 | |||
151 | return $this->claimedId; |
||
152 | } |
||
153 | |||
154 | /** |
||
155 | * @param string $claimedId claimed identifier (identity). |
||
156 | */ |
||
157 | public function setClaimedId(string $claimedId): void |
||
158 | { |
||
159 | $this->claimedId = $claimedId; |
||
160 | } |
||
161 | |||
162 | /** |
||
163 | * Performs Yadis and HTML discovery. |
||
164 | * |
||
165 | * @param string $url Identity URL. |
||
166 | * |
||
167 | * @return array OpenID provider info, following keys will be available: |
||
168 | * |
||
169 | * - url: string, OP Endpoint (i.e. OpenID provider address). |
||
170 | * - version: int, OpenID protocol version used by provider. |
||
171 | * - identity: string, identity value. |
||
172 | * - identifier_select: bool, whether to request OP to select identity for an user in OpenID 2, does not affect OpenID 1. |
||
173 | * - ax: bool, whether AX attributes should be used. |
||
174 | * - sreg: bool, whether SREG attributes should be used. |
||
175 | */ |
||
176 | public function discover(string $url): array |
||
177 | { |
||
178 | if (empty($url)) { |
||
179 | throw new \RuntimeException('No identity supplied.'); |
||
180 | } |
||
181 | $result = [ |
||
182 | 'url' => null, |
||
183 | 'version' => null, |
||
184 | 'identity' => $url, |
||
185 | 'identifier_select' => false, |
||
186 | 'ax' => false, |
||
187 | 'sreg' => false, |
||
188 | ]; |
||
189 | |||
190 | // Use xri.net proxy to resolve i-name identities |
||
191 | if (!preg_match('#^https?:#', $url)) { |
||
192 | $url = 'https://xri.net/' . $url; |
||
193 | } |
||
194 | |||
195 | /* We save the original url in case of Yadis discovery failure. |
||
196 | It can happen when we'll be lead to an XRDS document |
||
197 | which does not have any OpenID2 services.*/ |
||
198 | $originalUrl = $url; |
||
199 | |||
200 | // A flag to disable yadis discovery in case of failure in headers. |
||
201 | $yadis = true; |
||
202 | |||
203 | // We'll jump a maximum of 5 times, to avoid endless redirections. |
||
204 | for ($i = 0; $i < 5; $i++) { |
||
205 | if ($yadis) { |
||
206 | $request = $this->createRequest('HEAD', $url); |
||
207 | $headers = []; |
||
208 | $response = $this->sendRequest($request); |
||
209 | foreach ($response->getHeaders() as $name => $values) { |
||
210 | $headers[strtolower($name)] = array_pop($values); |
||
211 | } |
||
212 | |||
213 | $next = false; |
||
214 | if (isset($headers['x-xrds-location'])) { |
||
215 | $url = $this->buildUrl($url, trim($headers['x-xrds-location'])); |
||
216 | $next = true; |
||
217 | } |
||
218 | |||
219 | if (isset($headers['content-type']) |
||
220 | && (strpos($headers['content-type'], 'application/xrds+xml') !== false |
||
221 | || strpos($headers['content-type'], 'text/xml') !== false) |
||
222 | ) { |
||
223 | /* Apparently, some providers return XRDS documents as text/html. |
||
224 | While it is against the spec, allowing this here shouldn't break |
||
225 | compatibility with anything. |
||
226 | --- |
||
227 | Found an XRDS document, now let's find the server, and optionally delegate.*/ |
||
228 | $content = $this->sendRequest( |
||
229 | $request->withMethod('GET')->withUri($request->getUri()->withPath($url)) |
||
230 | )->getBody()->getContents(); |
||
231 | |||
232 | preg_match_all('#<Service.*?>(.*?)</Service>#s', $content, $m); |
||
233 | foreach ($m[1] as $content) { |
||
234 | $content = ' ' . $content; // The space is added, so that strpos doesn't return 0. |
||
235 | |||
236 | // OpenID 2 |
||
237 | $ns = preg_quote('http://specs.openid.net/auth/2.0/', '#'); |
||
238 | if (preg_match('#<Type>\s*' . $ns . '(server|signon)\s*</Type>#s', $content, $type)) { |
||
239 | if ($type[1] === 'server') { |
||
240 | $result['identifier_select'] = true; |
||
241 | } |
||
242 | |||
243 | preg_match('#<URI.*?>(.*)</URI>#', $content, $server); |
||
244 | preg_match('#<(Local|Canonical)ID>(.*)</\1ID>#', $content, $delegate); |
||
245 | if (empty($server)) { |
||
246 | throw new \RuntimeException('No servers found!'); |
||
247 | } |
||
248 | // Does the server advertise support for either AX or SREG? |
||
249 | $result['ax'] = (bool)strpos($content, '<Type>http://openid.net/srv/ax/1.0</Type>'); |
||
250 | $result['sreg'] = strpos($content, '<Type>http://openid.net/sreg/1.0</Type>') |
||
251 | || strpos($content, '<Type>http://openid.net/extensions/sreg/1.1</Type>'); |
||
252 | |||
253 | $server = $server[1]; |
||
254 | if (isset($delegate[2])) { |
||
255 | $result['identity'] = trim($delegate[2]); |
||
256 | } |
||
257 | |||
258 | $result['url'] = $server; |
||
259 | $result['version'] = 2; |
||
260 | |||
261 | return $result; |
||
262 | } |
||
263 | |||
264 | // OpenID 1.1 |
||
265 | $ns = preg_quote('http://openid.net/signon/1.1', '#'); |
||
266 | if (preg_match('#<Type>\s*' . $ns . '\s*</Type>#s', $content)) { |
||
267 | preg_match('#<URI.*?>(.*)</URI>#', $content, $server); |
||
268 | preg_match('#<.*?Delegate>(.*)</.*?Delegate>#', $content, $delegate); |
||
269 | if (empty($server)) { |
||
270 | throw new \RuntimeException('No servers found!'); |
||
271 | } |
||
272 | // AX can be used only with OpenID 2.0, so checking only SREG |
||
273 | $result['sreg'] = strpos($content, '<Type>http://openid.net/sreg/1.0</Type>') |
||
274 | || strpos($content, '<Type>http://openid.net/extensions/sreg/1.1</Type>'); |
||
275 | |||
276 | $server = $server[1]; |
||
277 | if (isset($delegate[1])) { |
||
278 | $result['identity'] = $delegate[1]; |
||
279 | } |
||
280 | |||
281 | $result['url'] = $server; |
||
282 | $result['version'] = 1; |
||
283 | |||
284 | return $result; |
||
285 | } |
||
286 | } |
||
287 | |||
288 | $next = true; |
||
0 ignored issues
–
show
Unused Code
introduced
by
![]() |
|||
289 | $yadis = false; |
||
0 ignored issues
–
show
|
|||
290 | $url = $originalUrl; |
||
0 ignored issues
–
show
|
|||
291 | $content = null; |
||
0 ignored issues
–
show
|
|||
292 | break; |
||
293 | } |
||
294 | if ($next) { |
||
295 | continue; |
||
296 | } |
||
297 | |||
298 | // There are no relevant information in headers, so we search the body. |
||
299 | $content = $this->sendRequest( |
||
300 | $request->withMethod('GET')->withUri($request->getUri()->withPath($url)) |
||
301 | )->getBody()->getContents(); |
||
302 | |||
303 | $location = $this->extractHtmlTagValue($content, 'meta', 'http-equiv', 'X-XRDS-Location', 'content'); |
||
304 | if ($location) { |
||
305 | $url = $this->buildUrl($url, $location); |
||
306 | continue; |
||
307 | } |
||
308 | } |
||
309 | |||
310 | if (!isset($content)) { |
||
311 | $request = $this->createRequest('GET', $url); |
||
312 | $content = $this->sendRequest($request); |
||
313 | } |
||
314 | |||
315 | // At this point, the YADIS Discovery has failed, so we'll switch to openid2 HTML discovery, then fallback to openid 1.1 discovery. |
||
316 | $server = $this->extractHtmlTagValue($content, 'link', 'rel', 'openid2.provider', 'href'); |
||
317 | if (!$server) { |
||
318 | // The same with openid 1.1 |
||
319 | $server = $this->extractHtmlTagValue($content, 'link', 'rel', 'openid.server', 'href'); |
||
320 | $delegate = $this->extractHtmlTagValue($content, 'link', 'rel', 'openid.delegate', 'href'); |
||
321 | $version = 1; |
||
322 | } else { |
||
323 | $delegate = $this->extractHtmlTagValue($content, 'link', 'rel', 'openid2.local_id', 'href'); |
||
324 | $version = 2; |
||
325 | } |
||
326 | |||
327 | if ($server) { |
||
328 | // We found an OpenID2 OP Endpoint |
||
329 | if ($delegate) { |
||
330 | // We have also found an OP-Local ID. |
||
331 | $result['identity'] = $delegate; |
||
332 | } |
||
333 | $result['url'] = $server; |
||
334 | $result['version'] = $version; |
||
335 | |||
336 | return $result; |
||
337 | } |
||
338 | throw new \RuntimeException('No servers found!'); |
||
339 | } |
||
340 | throw new \RuntimeException('Endless redirection!'); |
||
341 | } |
||
342 | |||
343 | /** |
||
344 | * Combines given URLs into single one. |
||
345 | * |
||
346 | * @param string $baseUrl base URL. |
||
347 | * @param array|string $additionalUrl additional URL string or information array. |
||
348 | * |
||
349 | * @return string composed URL. |
||
350 | */ |
||
351 | protected function buildUrl(string $baseUrl, $additionalUrl): string |
||
352 | { |
||
353 | $baseUrl = parse_url($baseUrl); |
||
354 | if (!is_array($additionalUrl)) { |
||
355 | $additionalUrl = parse_url($additionalUrl); |
||
356 | } |
||
357 | |||
358 | if (isset($baseUrl['query'], $additionalUrl['query'])) { |
||
359 | $additionalUrl['query'] = $baseUrl['query'] . '&' . $additionalUrl['query']; |
||
360 | } |
||
361 | |||
362 | $urlInfo = array_merge($baseUrl, $additionalUrl); |
||
363 | return $urlInfo['scheme'] . '://' |
||
364 | . (empty($urlInfo['username']) ? '' |
||
365 | : (empty($urlInfo['password']) ? "{$urlInfo['username']}@" |
||
366 | : "{$urlInfo['username']}:{$urlInfo['password']}@")) |
||
367 | . $urlInfo['host'] |
||
368 | . (empty($urlInfo['port']) ? '' : ":{$urlInfo['port']}") |
||
369 | . (empty($urlInfo['path']) ? '' : $urlInfo['path']) |
||
370 | . (empty($urlInfo['query']) ? '' : "?{$urlInfo['query']}") |
||
371 | . (empty($urlInfo['fragment']) ? '' : "#{$urlInfo['fragment']}"); |
||
372 | } |
||
373 | |||
374 | /** |
||
375 | * Scans content for <meta>/<link> tags and extract information from them. |
||
376 | * |
||
377 | * @param string $content HTML content to be be parsed. |
||
378 | * @param string $tag name of the source tag. |
||
379 | * @param string $matchAttributeName name of the source tag attribute, which should contain $matchAttributeValue |
||
380 | * @param string $matchAttributeValue required value of $matchAttributeName |
||
381 | * @param string $valueAttributeName name of the source tag attribute, which should contain searched value. |
||
382 | * |
||
383 | * @return bool|string searched value, "false" on failure. |
||
384 | */ |
||
385 | protected function extractHtmlTagValue( |
||
386 | string $content, |
||
387 | string $tag, |
||
388 | string $matchAttributeName, |
||
389 | string $matchAttributeValue, |
||
390 | string $valueAttributeName |
||
391 | ) { |
||
392 | preg_match_all( |
||
393 | "#<{$tag}[^>]*$matchAttributeName=['\"].*?$matchAttributeValue.*?['\"][^>]*$valueAttributeName=['\"](.+?)['\"][^>]*/?>#i", |
||
394 | $content, |
||
395 | $matches1 |
||
396 | ); |
||
397 | preg_match_all( |
||
398 | "#<{$tag}[^>]*$valueAttributeName=['\"](.+?)['\"][^>]*$matchAttributeName=['\"].*?$matchAttributeValue.*?['\"][^>]*/?>#i", |
||
399 | $content, |
||
400 | $matches2 |
||
401 | ); |
||
402 | $result = array_merge($matches1[1], $matches2[1]); |
||
403 | |||
404 | return empty($result) ? false : $result[0]; |
||
405 | } |
||
406 | |||
407 | /** |
||
408 | * Builds authentication URL for the protocol version 2. |
||
409 | * |
||
410 | * @param ServerRequestInterface $incomingRequest |
||
411 | * @param array $serverInfo OpenID server info. |
||
412 | * |
||
413 | * @return string authentication URL. |
||
414 | */ |
||
415 | protected function buildAuthUrlV2(ServerRequestInterface $incomingRequest, array $serverInfo) |
||
416 | { |
||
417 | $params = [ |
||
418 | 'openid.ns' => 'http://specs.openid.net/auth/2.0', |
||
419 | 'openid.mode' => 'checkid_setup', |
||
420 | 'openid.return_to' => $this->getReturnUrl($incomingRequest), |
||
421 | 'openid.realm' => $this->getTrustRoot(), |
||
422 | ]; |
||
423 | if ($serverInfo['ax']) { |
||
424 | $params = array_merge($params, $this->buildAxParams()); |
||
425 | } |
||
426 | if ($serverInfo['sreg']) { |
||
427 | $params = array_merge($params, $this->buildSregParams()); |
||
428 | } |
||
429 | if (!$serverInfo['ax'] && !$serverInfo['sreg']) { |
||
430 | // If OP doesn't advertise either SREG, nor AX, let's send them both in worst case we don't get anything in return. |
||
431 | $params = array_merge($this->buildSregParams(), $this->buildAxParams(), $params); |
||
432 | } |
||
433 | |||
434 | if ($serverInfo['identifier_select']) { |
||
435 | $url = 'http://specs.openid.net/auth/2.0/identifier_select'; |
||
436 | $params['openid.identity'] = $url; |
||
437 | $params['openid.claimed_id'] = $url; |
||
438 | } else { |
||
439 | $params['openid.identity'] = $serverInfo['identity']; |
||
440 | $params['openid.claimed_id'] = $this->getClaimedId(); |
||
441 | } |
||
442 | |||
443 | return $this->buildUrl($serverInfo['url'], $params); |
||
444 | } |
||
445 | |||
446 | /** |
||
447 | * @param ServerRequestInterface $incomingRequest |
||
448 | * |
||
449 | * @return string authentication return URL. |
||
450 | */ |
||
451 | public function getReturnUrl(ServerRequestInterface $incomingRequest): string |
||
452 | { |
||
453 | if ($this->returnUrl === null) { |
||
454 | $this->returnUrl = $this->defaultReturnUrl($incomingRequest); |
||
455 | } |
||
456 | |||
457 | return $this->returnUrl; |
||
458 | } |
||
459 | |||
460 | /** |
||
461 | * @param string $returnUrl authentication return URL. |
||
462 | */ |
||
463 | public function setReturnUrl(string $returnUrl): void |
||
464 | { |
||
465 | $this->returnUrl = $returnUrl; |
||
466 | } |
||
467 | |||
468 | /** |
||
469 | * Generates default {@see returnUrl} value. |
||
470 | * |
||
471 | * @param ServerRequestInterface $incomingRequest |
||
472 | * |
||
473 | * @return string default authentication return URL. |
||
474 | */ |
||
475 | protected function defaultReturnUrl(ServerRequestInterface $incomingRequest): string |
||
476 | { |
||
477 | $params = $incomingRequest->getQueryParams(); |
||
478 | foreach ($params as $name => $value) { |
||
479 | if (strncmp('openid', $name, 6) === 0) { |
||
480 | unset($params[$name]); |
||
481 | } |
||
482 | } |
||
483 | return (string)$incomingRequest->getUri()->withQuery(http_build_query($params, '', '&', PHP_QUERY_RFC3986)); |
||
484 | } |
||
485 | |||
486 | /** |
||
487 | * @return string client trust root (realm). |
||
488 | */ |
||
489 | public function getTrustRoot(): string |
||
490 | { |
||
491 | return $this->trustRoot; |
||
492 | } |
||
493 | |||
494 | /** |
||
495 | * @param string $value client trust root (realm). |
||
496 | */ |
||
497 | public function setTrustRoot(string $value): void |
||
498 | { |
||
499 | $this->trustRoot = $value; |
||
500 | } |
||
501 | |||
502 | /** |
||
503 | * Composes AX request parameters. |
||
504 | * |
||
505 | * @return array AX parameters. |
||
506 | */ |
||
507 | protected function buildAxParams(): array |
||
508 | { |
||
509 | $params = []; |
||
510 | if (!empty($this->requiredAttributes) || !empty($this->optionalAttributes)) { |
||
511 | $params['openid.ns.ax'] = 'http://openid.net/srv/ax/1.0'; |
||
512 | $params['openid.ax.mode'] = 'fetch_request'; |
||
513 | $aliases = []; |
||
514 | $counts = []; |
||
515 | $requiredAttributes = []; |
||
516 | $optionalAttributes = []; |
||
517 | foreach (['requiredAttributes', 'optionalAttributes'] as $type) { |
||
518 | foreach ($this->$type as $alias => $field) { |
||
519 | if (is_int($alias)) { |
||
520 | $alias = strtr($field, '/', '_'); |
||
521 | } |
||
522 | $aliases[$alias] = 'http://axschema.org/' . $field; |
||
523 | if (empty($counts[$alias])) { |
||
524 | $counts[$alias] = 0; |
||
525 | } |
||
526 | ++$counts[$alias]; |
||
527 | ${$type}[] = $alias; |
||
528 | } |
||
529 | } |
||
530 | foreach ($aliases as $alias => $ns) { |
||
531 | $params['openid.ax.type.' . $alias] = $ns; |
||
532 | } |
||
533 | foreach ($counts as $alias => $count) { |
||
534 | if ($count == 1) { |
||
535 | continue; |
||
536 | } |
||
537 | $params['openid.ax.count.' . $alias] = $count; |
||
538 | } |
||
539 | |||
540 | // Don't send empty ax.required and ax.if_available. |
||
541 | // Google and possibly other providers refuse to support ax when one of these is empty. |
||
542 | if (!empty($requiredAttributes)) { |
||
543 | $params['openid.ax.required'] = implode(',', $requiredAttributes); |
||
544 | } |
||
545 | if (!empty($optionalAttributes)) { |
||
546 | $params['openid.ax.if_available'] = implode(',', $optionalAttributes); |
||
547 | } |
||
548 | } |
||
549 | |||
550 | return $params; |
||
551 | } |
||
552 | |||
553 | /** |
||
554 | * Composes SREG request parameters. |
||
555 | * |
||
556 | * @return array SREG parameters. |
||
557 | */ |
||
558 | protected function buildSregParams(): array |
||
559 | { |
||
560 | $params = []; |
||
561 | /* We always use SREG 1.1, even if the server is advertising only support for 1.0. |
||
562 | That's because it's fully backwards compatible with 1.0, and some providers |
||
563 | advertise 1.0 even if they accept only 1.1. One such provider is myopenid.com */ |
||
564 | $params['openid.ns.sreg'] = 'http://openid.net/extensions/sreg/1.1'; |
||
565 | if (!empty($this->requiredAttributes)) { |
||
566 | $params['openid.sreg.required'] = []; |
||
567 | foreach ($this->requiredAttributes as $required) { |
||
568 | if (!isset($this->axToSregMap[$required])) { |
||
569 | continue; |
||
570 | } |
||
571 | $params['openid.sreg.required'][] = $this->axToSregMap[$required]; |
||
572 | } |
||
573 | $params['openid.sreg.required'] = implode(',', $params['openid.sreg.required']); |
||
574 | } |
||
575 | |||
576 | if (!empty($this->optionalAttributes)) { |
||
577 | $params['openid.sreg.optional'] = []; |
||
578 | foreach ($this->optionalAttributes as $optional) { |
||
579 | if (!isset($this->axToSregMap[$optional])) { |
||
580 | continue; |
||
581 | } |
||
582 | $params['openid.sreg.optional'][] = $this->axToSregMap[$optional]; |
||
583 | } |
||
584 | $params['openid.sreg.optional'] = implode(',', $params['openid.sreg.optional']); |
||
585 | } |
||
586 | |||
587 | return $params; |
||
588 | } |
||
589 | |||
590 | /** |
||
591 | * Builds authentication URL for the protocol version 1. |
||
592 | * |
||
593 | * @param ServerRequestInterface $incomingRequest |
||
594 | * @param array $serverInfo OpenID server info. |
||
595 | * |
||
596 | * @return string authentication URL. |
||
597 | */ |
||
598 | protected function buildAuthUrlV1(ServerRequestInterface $incomingRequest, array $serverInfo) |
||
599 | { |
||
600 | $returnUrl = $this->getReturnUrl($incomingRequest); |
||
601 | /* If we have an openid.delegate that is different from our claimed id, |
||
602 | we need to somehow preserve the claimed id between requests. |
||
603 | The simplest way is to just send it along with the return_to url.*/ |
||
604 | if ($serverInfo['identity'] !== $this->getClaimedId()) { |
||
605 | $returnUrl .= (strpos($returnUrl, '?') ? '&' : '?') . 'openid.claimed_id=' . $this->getClaimedId(); |
||
606 | } |
||
607 | |||
608 | $params = array_merge( |
||
609 | [ |
||
610 | 'openid.return_to' => $returnUrl, |
||
611 | 'openid.mode' => 'checkid_setup', |
||
612 | 'openid.identity' => $serverInfo['identity'], |
||
613 | 'openid.trust_root' => $this->trustRoot, |
||
614 | ], |
||
615 | $this->buildSregParams() |
||
616 | ); |
||
617 | |||
618 | return $this->buildUrl($serverInfo['url'], ['query' => http_build_query($params, '', '&')]); |
||
619 | } |
||
620 | |||
621 | /** |
||
622 | * Performs OpenID verification with the OP. |
||
623 | * |
||
624 | * @param bool $validateRequiredAttributes whether to validate required attributes. |
||
625 | * |
||
626 | * @return bool whether the verification was successful. |
||
627 | */ |
||
628 | public function validate(bool $validateRequiredAttributes = true): bool |
||
629 | { |
||
630 | $claimedId = $this->getClaimedId(); |
||
631 | if (empty($claimedId)) { |
||
632 | return false; |
||
633 | } |
||
634 | $params = [ |
||
635 | 'openid.assoc_handle' => $this->data['openid_assoc_handle'], |
||
636 | 'openid.signed' => $this->data['openid_signed'], |
||
637 | 'openid.sig' => $this->data['openid_sig'], |
||
638 | ]; |
||
639 | |||
640 | if (isset($this->data['openid_ns'])) { |
||
641 | /* We're dealing with an OpenID 2.0 server, so let's set an ns |
||
642 | Even though we should know location of the endpoint, |
||
643 | we still need to verify it by discovery, so $server is not set here*/ |
||
644 | $params['openid.ns'] = 'http://specs.openid.net/auth/2.0'; |
||
645 | } elseif (isset($this->data['openid_claimed_id']) && $this->data['openid_claimed_id'] !== $this->data['openid_identity']) { |
||
646 | // If it's an OpenID 1 provider, and we've got claimed_id, |
||
647 | // we have to append it to the returnUrl, like authUrlV1 does. |
||
648 | $this->returnUrl .= (strpos($this->returnUrl, '?') ? '&' : '?') . 'openid.claimed_id=' . $claimedId; |
||
649 | } |
||
650 | |||
651 | if (!$this->compareUrl($this->data['openid_return_to'], $this->returnUrl)) { |
||
652 | // The return_to url must match the url of current request. |
||
653 | return false; |
||
654 | } |
||
655 | |||
656 | $serverInfo = $this->discover($claimedId); |
||
657 | |||
658 | foreach (explode(',', $this->data['openid_signed']) as $item) { |
||
659 | $value = $this->data['openid_' . str_replace('.', '_', $item)]; |
||
660 | $params['openid.' . $item] = $value; |
||
661 | } |
||
662 | |||
663 | $params['openid.mode'] = 'check_authentication'; |
||
664 | $request = $this->createRequest('POST', $serverInfo['url']); |
||
665 | $request->getBody()->write((string)$params); |
||
666 | |||
667 | $response = $this->sendRequest($request); |
||
668 | |||
669 | if (preg_match('/is_valid\s*:\s*true/i', $response)) { |
||
670 | if ($validateRequiredAttributes) { |
||
671 | return $this->validateRequiredAttributes(); |
||
672 | } |
||
673 | |||
674 | return true; |
||
675 | } |
||
676 | |||
677 | return false; |
||
678 | } |
||
679 | |||
680 | /** |
||
681 | * Compares 2 URLs taking in account possible GET parameters order miss match and URL encoding inconsistencies. |
||
682 | * |
||
683 | * @param string $expectedUrl expected URL. |
||
684 | * @param string $actualUrl actual URL. |
||
685 | * |
||
686 | * @return bool whether URLs are equal. |
||
687 | */ |
||
688 | protected function compareUrl(string $expectedUrl, string $actualUrl): bool |
||
689 | { |
||
690 | $expectedUrlInfo = parse_url($expectedUrl); |
||
691 | $actualUrlInfo = parse_url($actualUrl); |
||
692 | foreach ($expectedUrlInfo as $name => $expectedValue) { |
||
693 | if ($name === 'query') { |
||
694 | parse_str($expectedValue, $expectedUrlParams); |
||
695 | parse_str($actualUrlInfo[$name], $actualUrlParams); |
||
696 | $paramsDiff = array_diff_assoc($expectedUrlParams, $actualUrlParams); |
||
697 | if (!empty($paramsDiff)) { |
||
698 | return false; |
||
699 | } |
||
700 | } elseif ($expectedValue !== $actualUrlInfo[$name]) { |
||
701 | return false; |
||
702 | } |
||
703 | } |
||
704 | return true; |
||
705 | } |
||
706 | |||
707 | /** |
||
708 | * Checks if all required attributes are present in the server response. |
||
709 | * |
||
710 | * @return bool whether all required attributes are present. |
||
711 | */ |
||
712 | protected function validateRequiredAttributes(): bool |
||
713 | { |
||
714 | if (!empty($this->requiredAttributes)) { |
||
715 | $attributes = $this->fetchAttributes(); |
||
716 | foreach ($this->requiredAttributes as $openIdAttributeName) { |
||
717 | if (!isset($attributes[$openIdAttributeName])) { |
||
718 | return false; |
||
719 | } |
||
720 | } |
||
721 | } |
||
722 | |||
723 | return true; |
||
724 | } |
||
725 | |||
726 | /** |
||
727 | * Gets AX/SREG attributes provided by OP. Should be used only after successful validation. |
||
728 | * Note that it does not guarantee that any of the required/optional parameters will be present, |
||
729 | * or that there will be no other attributes besides those specified. |
||
730 | * In other words. OP may provide whatever information it wants to. |
||
731 | * SREG names will be mapped to AX names. |
||
732 | * |
||
733 | * @return array array of attributes with keys being the AX schema names, e.g. 'contact/email' |
||
734 | * |
||
735 | * @see http://www.axschema.org/types/ |
||
736 | */ |
||
737 | public function fetchAttributes(): array |
||
738 | { |
||
739 | if (isset($this->data['openid_ns']) && $this->data['openid_ns'] === 'http://specs.openid.net/auth/2.0') { |
||
740 | // OpenID 2.0 |
||
741 | // We search for both AX and SREG attributes, with AX taking precedence. |
||
742 | return array_merge($this->fetchSregAttributes(), $this->fetchAxAttributes()); |
||
743 | } |
||
744 | |||
745 | return $this->fetchSregAttributes(); |
||
746 | } |
||
747 | |||
748 | /** |
||
749 | * Gets SREG attributes provided by OP. SREG names will be mapped to AX names. |
||
750 | * |
||
751 | * @return array array of attributes with keys being the AX schema names, e.g. 'contact/email' |
||
752 | */ |
||
753 | protected function fetchSregAttributes() |
||
754 | { |
||
755 | $attributes = []; |
||
756 | $sregToAx = array_flip($this->axToSregMap); |
||
757 | foreach ($this->data as $key => $value) { |
||
758 | $keyMatch = 'openid_sreg_'; |
||
759 | if (strncmp($key, $keyMatch, strlen($keyMatch))) { |
||
760 | continue; |
||
761 | } |
||
762 | $key = substr($key, strlen($keyMatch)); |
||
763 | if (!isset($sregToAx[$key])) { |
||
764 | // The field name isn't part of the SREG spec, so we ignore it. |
||
765 | continue; |
||
766 | } |
||
767 | $attributes[$sregToAx[$key]] = $value; |
||
768 | } |
||
769 | |||
770 | return $attributes; |
||
771 | } |
||
772 | |||
773 | /** |
||
774 | * Gets AX attributes provided by OP. |
||
775 | * |
||
776 | * @return array array of attributes. |
||
777 | */ |
||
778 | protected function fetchAxAttributes(): array |
||
779 | { |
||
780 | $alias = null; |
||
781 | if (isset($this->data['openid_ns_ax']) && $this->data['openid_ns_ax'] !== 'http://openid.net/srv/ax/1.0') { |
||
782 | // It's the most likely case, so we'll check it before |
||
783 | $alias = 'ax'; |
||
784 | } else { |
||
785 | // 'ax' prefix is either undefined, or points to another extension, so we search for another prefix |
||
786 | foreach ($this->data as $key => $value) { |
||
787 | if ($value === 'http://openid.net/srv/ax/1.0' && strncmp($key, 'openid_ns_', 10) === 0) { |
||
788 | $alias = substr($key, strlen('openid_ns_')); |
||
789 | break; |
||
790 | } |
||
791 | } |
||
792 | } |
||
793 | if (!$alias) { |
||
794 | // An alias for AX schema has not been found, so there is no AX data in the OP's response |
||
795 | return []; |
||
796 | } |
||
797 | |||
798 | $attributes = []; |
||
799 | foreach ($this->data as $key => $value) { |
||
800 | $keyMatch = 'openid_' . $alias . '_value_'; |
||
801 | if (strncmp($key, $keyMatch, strlen($keyMatch))) { |
||
802 | continue; |
||
803 | } |
||
804 | $key = substr($key, strlen($keyMatch)); |
||
805 | if (!isset($this->data['openid_' . $alias . '_type_' . $key])) { |
||
806 | /* OP is breaking the spec by returning a field without |
||
807 | associated ns. This shouldn't happen, but it's better |
||
808 | to check, than cause an E_NOTICE.*/ |
||
809 | continue; |
||
810 | } |
||
811 | $key = substr($this->data['openid_' . $alias . '_type_' . $key], strlen('http://axschema.org/')); |
||
812 | $attributes[$key] = $value; |
||
813 | } |
||
814 | |||
815 | return $attributes; |
||
816 | } |
||
817 | |||
818 | public function getName(): string |
||
819 | { |
||
820 | return (new Inflector())->pascalCaseToId(StringHelper::baseName(static::class)); |
||
821 | } |
||
822 | |||
823 | public function getTitle(): string |
||
824 | { |
||
825 | return StringHelper::baseName(static::class); |
||
826 | } |
||
827 | |||
828 | protected function initUserAttributes(): array |
||
829 | { |
||
830 | return array_merge(['id' => $this->getClaimedId()], $this->fetchAttributes()); |
||
831 | } |
||
832 | } |
||
833 |