Test Failed
Pull Request — master (#21)
by Rustam
08:44
created

AbstractOpenId::getReturnUrl()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 1
dl 0
loc 7
rs 10
c 0
b 0
f 0
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 http://openid.net/
35
 */
36
abstract class AbstractOpenId extends AbstractAuthClient
37
{
38
    /**
39
     * @var string authentication base URL, which should be used to compose actual authentication URL
40
     * by {@see buildAuthUrl()} method.
41
     */
42
    private 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
     *
116
     * @param array $params
117
     *
118
     * @return string the authentication URL.
119
     */
120
    public function buildAuthUrl(ServerRequestInterface $incomingRequest, array $params = []): string
121
    {
122
        $authUrl = $this->authUrl;
123
        $claimedId = $this->getClaimedId();
124
        if (empty($claimedId)) {
125
            $this->setClaimedId($authUrl);
126
        }
127
        $serverInfo = $this->discover($authUrl);
128
        if ($serverInfo['version'] === 2) {
129
            if (isset($params['identifierSelect'])) {
130
                $serverInfo['identifier_select'] = $params['identifierSelect'];
131
            }
132
133
            return $this->buildAuthUrlV2($incomingRequest, $serverInfo);
134
        }
135
136
        return $this->buildAuthUrlV1($incomingRequest, $serverInfo);
137
    }
138
139
    /**
140
     * @return string claimed identifier (identity).
141
     */
142
    public function getClaimedId(): string
143
    {
144
        if ($this->claimedId === null) {
145
            if (isset($this->data['openid_claimed_id'])) {
146
                $this->claimedId = $this->data['openid_claimed_id'];
147
            } elseif (isset($this->data['openid_identity'])) {
148
                $this->claimedId = $this->data['openid_identity'];
149
            }
150
        }
151
152
        return $this->claimedId;
153
    }
154
155
    /**
156
     * @param string $claimedId claimed identifier (identity).
157
     */
158
    public function setClaimedId(string $claimedId): void
159
    {
160
        $this->claimedId = $claimedId;
161
    }
162
163
    /**
164
     * Performs Yadis and HTML discovery.
165
     *
166
     * @param string $url Identity URL.
167
     *
168
     * @return array OpenID provider info, following keys will be available:
169
     *
170
     * - url: string, OP Endpoint (i.e. OpenID provider address).
171
     * - version: int, OpenID protocol version used by provider.
172
     * - identity: string, identity value.
173
     * - identifier_select: bool, whether to request OP to select identity for an user in OpenID 2, does not affect OpenID 1.
174
     * - ax: bool, whether AX attributes should be used.
175
     * - sreg: bool, whether SREG attributes should be used.
176
     */
177
    public function discover(string $url): array
178
    {
179
        if (empty($url)) {
180
            throw new \RuntimeException('No identity supplied.');
181
        }
182
        $result = [
183
            'url' => null,
184
            'version' => null,
185
            'identity' => $url,
186
            'identifier_select' => false,
187
            'ax' => false,
188
            'sreg' => false,
189
        ];
190
191
        // Use xri.net proxy to resolve i-name identities
192
        if (!preg_match('#^https?:#', $url)) {
193
            $url = 'https://xri.net/' . $url;
194
        }
195
196
        /* We save the original url in case of Yadis discovery failure.
197
        It can happen when we'll be lead to an XRDS document
198
        which does not have any OpenID2 services.*/
199
        $originalUrl = $url;
200
201
        // A flag to disable yadis discovery in case of failure in headers.
202
        $yadis = true;
203
204
        // We'll jump a maximum of 5 times, to avoid endless redirections.
205
        for ($i = 0; $i < 5; $i++) {
206
            if ($yadis) {
207
                $request = $this->createRequest('HEAD', $url);
208
                $headers = [];
209
                $response = $this->sendRequest($request);
210
                foreach ($response->getHeaders() as $name => $values) {
211
                    $headers[strtolower($name)] = array_pop($values);
212
                }
213
214
                $next = false;
215
                if (isset($headers['x-xrds-location'])) {
216
                    $url = $this->buildUrl($url, trim($headers['x-xrds-location']));
217
                    $next = true;
218
                }
219
220
                if (isset($headers['content-type'])
221
                    && (strpos($headers['content-type'], 'application/xrds+xml') !== false
222
                        || strpos($headers['content-type'], 'text/xml') !== false)
223
                ) {
224
                    /* Apparently, some providers return XRDS documents as text/html.
225
                    While it is against the spec, allowing this here shouldn't break
226
                    compatibility with anything.
227
                    ---
228
                    Found an XRDS document, now let's find the server, and optionally delegate.*/
229
                    $content = $this->sendRequest(
230
                        $request->withMethod('GET')->withUri($request->getUri()->withPath($url))
231
                    )->getBody()->getContents();
232
233
                    preg_match_all('#<Service.*?>(.*?)</Service>#s', $content, $m);
234
                    foreach ($m[1] as $content) {
235
                        $content = ' ' . $content; // The space is added, so that strpos doesn't return 0.
236
237
                        // OpenID 2
238
                        $ns = preg_quote('http://specs.openid.net/auth/2.0/', '#');
239
                        if (preg_match('#<Type>\s*' . $ns . '(server|signon)\s*</Type>#s', $content, $type)) {
240
                            if ($type[1] === 'server') {
241
                                $result['identifier_select'] = true;
242
                            }
243
244
                            preg_match('#<URI.*?>(.*)</URI>#', $content, $server);
245
                            preg_match('#<(Local|Canonical)ID>(.*)</\1ID>#', $content, $delegate);
246
                            if (empty($server)) {
247
                                throw new \RuntimeException('No servers found!');
248
                            }
249
                            // Does the server advertise support for either AX or SREG?
250
                            $result['ax'] = (bool)strpos($content, '<Type>http://openid.net/srv/ax/1.0</Type>');
251
                            $result['sreg'] = strpos($content, '<Type>http://openid.net/sreg/1.0</Type>')
252
                                || strpos($content, '<Type>http://openid.net/extensions/sreg/1.1</Type>');
253
254
                            $server = $server[1];
255
                            if (isset($delegate[2])) {
256
                                $result['identity'] = trim($delegate[2]);
257
                            }
258
259
                            $result['url'] = $server;
260
                            $result['version'] = 2;
261
262
                            return $result;
263
                        }
264
265
                        // OpenID 1.1
266
                        $ns = preg_quote('http://openid.net/signon/1.1', '#');
267
                        if (preg_match('#<Type>\s*' . $ns . '\s*</Type>#s', $content)) {
268
                            preg_match('#<URI.*?>(.*)</URI>#', $content, $server);
269
                            preg_match('#<.*?Delegate>(.*)</.*?Delegate>#', $content, $delegate);
270
                            if (empty($server)) {
271
                                throw new \RuntimeException('No servers found!');
272
                            }
273
                            // AX can be used only with OpenID 2.0, so checking only SREG
274
                            $result['sreg'] = strpos($content, '<Type>http://openid.net/sreg/1.0</Type>')
275
                                || strpos($content, '<Type>http://openid.net/extensions/sreg/1.1</Type>');
276
277
                            $server = $server[1];
278
                            if (isset($delegate[1])) {
279
                                $result['identity'] = $delegate[1];
280
                            }
281
282
                            $result['url'] = $server;
283
                            $result['version'] = 1;
284
285
                            return $result;
286
                        }
287
                    }
288
289
                    $next = true;
0 ignored issues
show
Unused Code introduced by
The assignment to $next is dead and can be removed.
Loading history...
290
                    $yadis = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $yadis is dead and can be removed.
Loading history...
291
                    $url = $originalUrl;
0 ignored issues
show
Unused Code introduced by
The assignment to $url is dead and can be removed.
Loading history...
292
                    $content = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $content is dead and can be removed.
Loading history...
293
                    break;
294
                }
295
                if ($next) {
296
                    continue;
297
                }
298
299
                // There are no relevant information in headers, so we search the body.
300
                $content = $this->sendRequest(
301
                    $request->withMethod('GET')->withUri($request->getUri()->withPath($url))
302
                )->getBody()->getContents();
303
304
                $location = $this->extractHtmlTagValue($content, 'meta', 'http-equiv', 'X-XRDS-Location', 'content');
305
                if ($location) {
306
                    $url = $this->buildUrl($url, $location);
0 ignored issues
show
Bug introduced by
It seems like $location can also be of type true; however, parameter $additionalUrl of Yiisoft\Yii\AuthClient\AbstractOpenId::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 ignore-type  annotation

306
                    $url = $this->buildUrl($url, /** @scrutinizer ignore-type */ $location);
Loading history...
307
                    continue;
308
                }
309
            }
310
311
            if (!isset($content)) {
312
                $request = $this->createRequest('GET', $url);
313
                $content = $this->sendRequest($request);
314
            }
315
316
            // At this point, the YADIS Discovery has failed, so we'll switch to openid2 HTML discovery, then fallback to openid 1.1 discovery.
317
            $server = $this->extractHtmlTagValue($content, 'link', 'rel', 'openid2.provider', 'href');
318
            if (!$server) {
319
                // The same with openid 1.1
320
                $server = $this->extractHtmlTagValue($content, 'link', 'rel', 'openid.server', 'href');
321
                $delegate = $this->extractHtmlTagValue($content, 'link', 'rel', 'openid.delegate', 'href');
322
                $version = 1;
323
            } else {
324
                $delegate = $this->extractHtmlTagValue($content, 'link', 'rel', 'openid2.local_id', 'href');
325
                $version = 2;
326
            }
327
328
            if ($server) {
329
                // We found an OpenID2 OP Endpoint
330
                if ($delegate) {
331
                    // We have also found an OP-Local ID.
332
                    $result['identity'] = $delegate;
333
                }
334
                $result['url'] = $server;
335
                $result['version'] = $version;
336
337
                return $result;
338
            }
339
            throw new \RuntimeException('No servers found!');
340
        }
341
        throw new \RuntimeException('Endless redirection!');
342
    }
343
344
    /**
345
     * Combines given URLs into single one.
346
     *
347
     * @param string $baseUrl base URL.
348
     * @param array|string $additionalUrl additional URL string or information array.
349
     *
350
     * @return string composed URL.
351
     */
352
    protected function buildUrl(string $baseUrl, $additionalUrl): string
353
    {
354
        $baseUrl = parse_url($baseUrl);
355
        if (!is_array($additionalUrl)) {
356
            $additionalUrl = parse_url($additionalUrl);
357
        }
358
359
        if (isset($baseUrl['query'], $additionalUrl['query'])) {
360
            $additionalUrl['query'] = $baseUrl['query'] . '&' . $additionalUrl['query'];
361
        }
362
363
        $urlInfo = array_merge($baseUrl, $additionalUrl);
364
        return $urlInfo['scheme'] . '://'
365
            . (empty($urlInfo['username']) ? ''
366
                : (empty($urlInfo['password']) ? "{$urlInfo['username']}@"
367
                    : "{$urlInfo['username']}:{$urlInfo['password']}@"))
368
            . $urlInfo['host']
369
            . (empty($urlInfo['port']) ? '' : ":{$urlInfo['port']}")
370
            . (empty($urlInfo['path']) ? '' : $urlInfo['path'])
371
            . (empty($urlInfo['query']) ? '' : "?{$urlInfo['query']}")
372
            . (empty($urlInfo['fragment']) ? '' : "#{$urlInfo['fragment']}");
373
    }
374
375
    /**
376
     * Scans content for <meta>/<link> tags and extract information from them.
377
     *
378
     * @param string $content HTML content to be be parsed.
379
     * @param string $tag name of the source tag.
380
     * @param string $matchAttributeName name of the source tag attribute, which should contain $matchAttributeValue
381
     * @param string $matchAttributeValue required value of $matchAttributeName
382
     * @param string $valueAttributeName name of the source tag attribute, which should contain searched value.
383
     *
384
     * @return bool|string searched value, "false" on failure.
385
     */
386
    protected function extractHtmlTagValue(
387
        string $content,
388
        string $tag,
389
        string $matchAttributeName,
390
        string $matchAttributeValue,
391
        string $valueAttributeName
392
    ) {
393
        preg_match_all(
394
            "#<{$tag}[^>]*$matchAttributeName=['\"].*?$matchAttributeValue.*?['\"][^>]*$valueAttributeName=['\"](.+?)['\"][^>]*/?>#i",
395
            $content,
396
            $matches1
397
        );
398
        preg_match_all(
399
            "#<{$tag}[^>]*$valueAttributeName=['\"](.+?)['\"][^>]*$matchAttributeName=['\"].*?$matchAttributeValue.*?['\"][^>]*/?>#i",
400
            $content,
401
            $matches2
402
        );
403
        $result = array_merge($matches1[1], $matches2[1]);
404
405
        return empty($result) ? false : $result[0];
406
    }
407
408
    /**
409
     * Builds authentication URL for the protocol version 2.
410
     *
411
     * @param ServerRequestInterface $incomingRequest
412
     * @param array $serverInfo OpenID server info.
413
     *
414
     * @return string authentication URL.
415
     */
416
    protected function buildAuthUrlV2(ServerRequestInterface $incomingRequest, array $serverInfo)
417
    {
418
        $params = [
419
            'openid.ns' => 'http://specs.openid.net/auth/2.0',
420
            'openid.mode' => 'checkid_setup',
421
            'openid.return_to' => $this->getReturnUrl($incomingRequest),
422
            'openid.realm' => $this->getTrustRoot(),
423
        ];
424
        if ($serverInfo['ax']) {
425
            $params = array_merge($params, $this->buildAxParams());
426
        }
427
        if ($serverInfo['sreg']) {
428
            $params = array_merge($params, $this->buildSregParams());
429
        }
430
        if (!$serverInfo['ax'] && !$serverInfo['sreg']) {
431
            // If OP doesn't advertise either SREG, nor AX, let's send them both in worst case we don't get anything in return.
432
            $params = array_merge($this->buildSregParams(), $this->buildAxParams(), $params);
433
        }
434
435
        if ($serverInfo['identifier_select']) {
436
            $url = 'http://specs.openid.net/auth/2.0/identifier_select';
437
            $params['openid.identity'] = $url;
438
            $params['openid.claimed_id'] = $url;
439
        } else {
440
            $params['openid.identity'] = $serverInfo['identity'];
441
            $params['openid.claimed_id'] = $this->getClaimedId();
442
        }
443
444
        return $this->buildUrl($serverInfo['url'], $params);
445
    }
446
447
    /**
448
     * @param ServerRequestInterface $incomingRequest
449
     *
450
     * @return string authentication return URL.
451
     */
452
    public function getReturnUrl(ServerRequestInterface $incomingRequest): string
453
    {
454
        if ($this->returnUrl === null) {
455
            $this->returnUrl = $this->defaultReturnUrl($incomingRequest);
456
        }
457
458
        return $this->returnUrl;
459
    }
460
461
    /**
462
     * @param string $returnUrl authentication return URL.
463
     */
464
    public function setReturnUrl(string $returnUrl): void
465
    {
466
        $this->returnUrl = $returnUrl;
467
    }
468
469
    /**
470
     * Generates default {@see returnUrl} value.
471
     *
472
     * @param ServerRequestInterface $incomingRequest
473
     *
474
     * @return string default authentication return URL.
475
     */
476
    protected function defaultReturnUrl(ServerRequestInterface $incomingRequest): string
477
    {
478
        $params = $incomingRequest->getQueryParams();
479
        foreach ($params as $name => $value) {
480
            if (strncmp('openid', $name, 6) === 0) {
481
                unset($params[$name]);
482
            }
483
        }
484
        return (string)$incomingRequest->getUri()->withQuery(http_build_query($params, '', '&', PHP_QUERY_RFC3986));
485
    }
486
487
    /**
488
     * @return string client trust root (realm).
489
     */
490
    public function getTrustRoot(): string
491
    {
492
        return $this->trustRoot;
493
    }
494
495
    /**
496
     * @param string $value client trust root (realm).
497
     */
498
    public function setTrustRoot(string $value): void
499
    {
500
        $this->trustRoot = $value;
501
    }
502
503
    /**
504
     * Composes AX request parameters.
505
     *
506
     * @return array AX parameters.
507
     */
508
    protected function buildAxParams(): array
509
    {
510
        $params = [];
511
        if (!empty($this->requiredAttributes) || !empty($this->optionalAttributes)) {
512
            $params['openid.ns.ax'] = 'http://openid.net/srv/ax/1.0';
513
            $params['openid.ax.mode'] = 'fetch_request';
514
            $aliases = [];
515
            $counts = [];
516
            $requiredAttributes = [];
517
            $optionalAttributes = [];
518
            foreach (['requiredAttributes', 'optionalAttributes'] as $type) {
519
                foreach ($this->$type as $alias => $field) {
520
                    if (is_int($alias)) {
521
                        $alias = strtr($field, '/', '_');
522
                    }
523
                    $aliases[$alias] = 'http://axschema.org/' . $field;
524
                    if (empty($counts[$alias])) {
525
                        $counts[$alias] = 0;
526
                    }
527
                    ++$counts[$alias];
528
                    ${$type}[] = $alias;
529
                }
530
            }
531
            foreach ($aliases as $alias => $ns) {
532
                $params['openid.ax.type.' . $alias] = $ns;
533
            }
534
            foreach ($counts as $alias => $count) {
535
                if ($count == 1) {
536
                    continue;
537
                }
538
                $params['openid.ax.count.' . $alias] = $count;
539
            }
540
541
            // Don't send empty ax.required and ax.if_available.
542
            // Google and possibly other providers refuse to support ax when one of these is empty.
543
            if (!empty($requiredAttributes)) {
544
                $params['openid.ax.required'] = implode(',', $requiredAttributes);
545
            }
546
            if (!empty($optionalAttributes)) {
547
                $params['openid.ax.if_available'] = implode(',', $optionalAttributes);
548
            }
549
        }
550
551
        return $params;
552
    }
553
554
    /**
555
     * Composes SREG request parameters.
556
     *
557
     * @return array SREG parameters.
558
     */
559
    protected function buildSregParams(): array
560
    {
561
        $params = [];
562
        /* We always use SREG 1.1, even if the server is advertising only support for 1.0.
563
        That's because it's fully backwards compatible with 1.0, and some providers
564
        advertise 1.0 even if they accept only 1.1. One such provider is myopenid.com */
565
        $params['openid.ns.sreg'] = 'http://openid.net/extensions/sreg/1.1';
566
        if (!empty($this->requiredAttributes)) {
567
            $params['openid.sreg.required'] = [];
568
            foreach ($this->requiredAttributes as $required) {
569
                if (!isset($this->axToSregMap[$required])) {
570
                    continue;
571
                }
572
                $params['openid.sreg.required'][] = $this->axToSregMap[$required];
573
            }
574
            $params['openid.sreg.required'] = implode(',', $params['openid.sreg.required']);
575
        }
576
577
        if (!empty($this->optionalAttributes)) {
578
            $params['openid.sreg.optional'] = [];
579
            foreach ($this->optionalAttributes as $optional) {
580
                if (!isset($this->axToSregMap[$optional])) {
581
                    continue;
582
                }
583
                $params['openid.sreg.optional'][] = $this->axToSregMap[$optional];
584
            }
585
            $params['openid.sreg.optional'] = implode(',', $params['openid.sreg.optional']);
586
        }
587
588
        return $params;
589
    }
590
591
    /**
592
     * Builds authentication URL for the protocol version 1.
593
     *
594
     * @param ServerRequestInterface $incomingRequest
595
     * @param array $serverInfo OpenID server info.
596
     *
597
     * @return string authentication URL.
598
     */
599
    protected function buildAuthUrlV1(ServerRequestInterface $incomingRequest, array $serverInfo)
600
    {
601
        $returnUrl = $this->getReturnUrl($incomingRequest);
602
        /* If we have an openid.delegate that is different from our claimed id,
603
        we need to somehow preserve the claimed id between requests.
604
        The simplest way is to just send it along with the return_to url.*/
605
        if ($serverInfo['identity'] !== $this->getClaimedId()) {
606
            $returnUrl .= (strpos($returnUrl, '?') ? '&' : '?') . 'openid.claimed_id=' . $this->getClaimedId();
607
        }
608
609
        $params = array_merge(
610
            [
611
                'openid.return_to' => $returnUrl,
612
                'openid.mode' => 'checkid_setup',
613
                'openid.identity' => $serverInfo['identity'],
614
                'openid.trust_root' => $this->trustRoot,
615
            ],
616
            $this->buildSregParams()
617
        );
618
619
        return $this->buildUrl($serverInfo['url'], ['query' => http_build_query($params, '', '&')]);
620
    }
621
622
    /**
623
     * Performs OpenID verification with the OP.
624
     *
625
     * @param bool $validateRequiredAttributes whether to validate required attributes.
626
     *
627
     * @return bool whether the verification was successful.
628
     */
629
    public function validate(bool $validateRequiredAttributes = true): bool
630
    {
631
        $claimedId = $this->getClaimedId();
632
        if (empty($claimedId)) {
633
            return false;
634
        }
635
        $params = [
636
            'openid.assoc_handle' => $this->data['openid_assoc_handle'],
637
            'openid.signed' => $this->data['openid_signed'],
638
            'openid.sig' => $this->data['openid_sig'],
639
        ];
640
641
        if (isset($this->data['openid_ns'])) {
642
            /* We're dealing with an OpenID 2.0 server, so let's set an ns
643
            Even though we should know location of the endpoint,
644
            we still need to verify it by discovery, so $server is not set here*/
645
            $params['openid.ns'] = 'http://specs.openid.net/auth/2.0';
646
        } elseif (isset($this->data['openid_claimed_id']) && $this->data['openid_claimed_id'] !== $this->data['openid_identity']) {
647
            // If it's an OpenID 1 provider, and we've got claimed_id,
648
            // we have to append it to the returnUrl, like authUrlV1 does.
649
            $this->returnUrl .= (strpos($this->returnUrl, '?') ? '&' : '?') . 'openid.claimed_id=' . $claimedId;
650
        }
651
652
        if (!$this->compareUrl($this->data['openid_return_to'], $this->returnUrl)) {
653
            // The return_to url must match the url of current request.
654
            return false;
655
        }
656
657
        $serverInfo = $this->discover($claimedId);
658
659
        foreach (explode(',', $this->data['openid_signed']) as $item) {
660
            $value = $this->data['openid_' . str_replace('.', '_', $item)];
661
            $params['openid.' . $item] = $value;
662
        }
663
664
        $params['openid.mode'] = 'check_authentication';
665
        $request = $this->createRequest('POST', $serverInfo['url']);
666
        $request->getBody()->write((string)$params);
667
668
        $response = $this->sendRequest($request);
669
670
        if (preg_match('/is_valid\s*:\s*true/i', $response)) {
0 ignored issues
show
Bug introduced by
$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 ignore-type  annotation

670
        if (preg_match('/is_valid\s*:\s*true/i', /** @scrutinizer ignore-type */ $response)) {
Loading history...
671
            if ($validateRequiredAttributes) {
672
                return $this->validateRequiredAttributes();
673
            }
674
675
            return true;
676
        }
677
678
        return false;
679
    }
680
681
    /**
682
     * Compares 2 URLs taking in account possible GET parameters order miss match and URL encoding inconsistencies.
683
     *
684
     * @param string $expectedUrl expected URL.
685
     * @param string $actualUrl actual URL.
686
     *
687
     * @return bool whether URLs are equal.
688
     */
689
    protected function compareUrl(string $expectedUrl, string $actualUrl): bool
690
    {
691
        $expectedUrlInfo = parse_url($expectedUrl);
692
        $actualUrlInfo = parse_url($actualUrl);
693
        foreach ($expectedUrlInfo as $name => $expectedValue) {
694
            if ($name === 'query') {
695
                parse_str($expectedValue, $expectedUrlParams);
696
                parse_str($actualUrlInfo[$name], $actualUrlParams);
697
                $paramsDiff = array_diff_assoc($expectedUrlParams, $actualUrlParams);
698
                if (!empty($paramsDiff)) {
699
                    return false;
700
                }
701
            } elseif ($expectedValue !== $actualUrlInfo[$name]) {
702
                return false;
703
            }
704
        }
705
        return true;
706
    }
707
708
    /**
709
     * Checks if all required attributes are present in the server response.
710
     *
711
     * @return bool whether all required attributes are present.
712
     */
713
    protected function validateRequiredAttributes(): bool
714
    {
715
        if (!empty($this->requiredAttributes)) {
716
            $attributes = $this->fetchAttributes();
717
            foreach ($this->requiredAttributes as $openIdAttributeName) {
718
                if (!isset($attributes[$openIdAttributeName])) {
719
                    return false;
720
                }
721
            }
722
        }
723
724
        return true;
725
    }
726
727
    /**
728
     * Gets AX/SREG attributes provided by OP. Should be used only after successful validation.
729
     * Note that it does not guarantee that any of the required/optional parameters will be present,
730
     * or that there will be no other attributes besides those specified.
731
     * In other words. OP may provide whatever information it wants to.
732
     * SREG names will be mapped to AX names.
733
     *
734
     * @return array array of attributes with keys being the AX schema names, e.g. 'contact/email'
735
     *
736
     * @see http://www.axschema.org/types/
737
     */
738
    public function fetchAttributes(): array
739
    {
740
        if (isset($this->data['openid_ns']) && $this->data['openid_ns'] === 'http://specs.openid.net/auth/2.0') {
741
            // OpenID 2.0
742
            // We search for both AX and SREG attributes, with AX taking precedence.
743
            return array_merge($this->fetchSregAttributes(), $this->fetchAxAttributes());
744
        }
745
746
        return $this->fetchSregAttributes();
747
    }
748
749
    /**
750
     * Gets SREG attributes provided by OP. SREG names will be mapped to AX names.
751
     *
752
     * @return array array of attributes with keys being the AX schema names, e.g. 'contact/email'
753
     */
754
    protected function fetchSregAttributes()
755
    {
756
        $attributes = [];
757
        $sregToAx = array_flip($this->axToSregMap);
758
        foreach ($this->data as $key => $value) {
759
            $keyMatch = 'openid_sreg_';
760
            if (strncmp($key, $keyMatch, strlen($keyMatch))) {
761
                continue;
762
            }
763
            $key = substr($key, strlen($keyMatch));
764
            if (!isset($sregToAx[$key])) {
765
                // The field name isn't part of the SREG spec, so we ignore it.
766
                continue;
767
            }
768
            $attributes[$sregToAx[$key]] = $value;
769
        }
770
771
        return $attributes;
772
    }
773
774
    /**
775
     * Gets AX attributes provided by OP.
776
     *
777
     * @return array array of attributes.
778
     */
779
    protected function fetchAxAttributes(): array
780
    {
781
        $alias = null;
782
        if (isset($this->data['openid_ns_ax']) && $this->data['openid_ns_ax'] !== 'http://openid.net/srv/ax/1.0') {
783
            // It's the most likely case, so we'll check it before
784
            $alias = 'ax';
785
        } else {
786
            // 'ax' prefix is either undefined, or points to another extension, so we search for another prefix
787
            foreach ($this->data as $key => $value) {
788
                if ($value === 'http://openid.net/srv/ax/1.0' && strncmp($key, 'openid_ns_', 10) === 0) {
789
                    $alias = substr($key, strlen('openid_ns_'));
790
                    break;
791
                }
792
            }
793
        }
794
        if (!$alias) {
795
            // An alias for AX schema has not been found, so there is no AX data in the OP's response
796
            return [];
797
        }
798
799
        $attributes = [];
800
        foreach ($this->data as $key => $value) {
801
            $keyMatch = 'openid_' . $alias . '_value_';
802
            if (strncmp($key, $keyMatch, strlen($keyMatch))) {
803
                continue;
804
            }
805
            $key = substr($key, strlen($keyMatch));
806
            if (!isset($this->data['openid_' . $alias . '_type_' . $key])) {
807
                /* OP is breaking the spec by returning a field without
808
                associated ns. This shouldn't happen, but it's better
809
                to check, than cause an E_NOTICE.*/
810
                continue;
811
            }
812
            $key = substr($this->data['openid_' . $alias . '_type_' . $key], strlen('http://axschema.org/'));
813
            $attributes[$key] = $value;
814
        }
815
816
        return $attributes;
817
    }
818
819
    public function getName(): string
820
    {
821
        return (new Inflector())->pascalCaseToId(StringHelper::baseName(static::class));
822
    }
823
824
    public function getTitle(): string
825
    {
826
        return StringHelper::baseName(static::class);
827
    }
828
829
    protected function initUserAttributes(): array
830
    {
831
        return array_merge(['id' => $this->getClaimedId()], $this->fetchAttributes());
832
    }
833
}
834