yiisoft /
yii-auth-client
| 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
Loading history...
|
|||||
| 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); |
||||
|
0 ignored issues
–
show
It seems like
$location can also be of type true; however, parameter $additionalUrl of Yiisoft\Yii\AuthClient\OpenId::buildUrl() does only seem to accept array|string, maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
Loading history...
|
|||||
| 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)) { |
||||
|
0 ignored issues
–
show
$response of type Psr\Http\Message\ResponseInterface is incompatible with the type string expected by parameter $subject of preg_match().
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
Loading history...
|
|||||
| 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 |