OpenId::getClaimedId()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 6
c 1
b 0
f 0
nc 4
nop 0
dl 0
loc 11
ccs 0
cts 7
cp 0
crap 20
rs 10
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
The assignment to $next is dead and can be removed.
Loading history...
289
                    $yadis = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $yadis is dead and can be removed.
Loading history...
290
                    $url = $originalUrl;
0 ignored issues
show
Unused Code introduced by
The assignment to $url is dead and can be removed.
Loading history...
291
                    $content = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $content is dead and can be removed.
Loading history...
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
Bug introduced by
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 ignore-type  annotation

305
                    $url = $this->buildUrl($url, /** @scrutinizer ignore-type */ $location);
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
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

669
        if (preg_match('/is_valid\s*:\s*true/i', /** @scrutinizer ignore-type */ $response)) {
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