1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2014-2025
6: *
7: * This program is free software; you can redistribute it and/or
8: * modify it under the terms of the GNU General Public
9: * License as published by the Free Software Foundation; either
10: * version 2 of the License, or (at your option) any later version.
11: *
12: * This program is distributed in the hope that it will be useful,
13: * but WITHOUT ANY WARRANTY; without even the implied warranty of
14: * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15: * General Public License for more details.
16: *
17: * You should have received a copy of the GNU General Public
18: * License along with this program; if not, write to the Free
19: * Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
20: *
21: */
22:
23: namespace SimpleID\Protocols\XRDS;
24:
25: use \Prefab;
26: use SimpleID\Protocols\HTTPResponse;
27:
28: /**
29: * Utility class for XRDS discovery.
30: */
31: class XRDSDiscovery extends Prefab {
32: /**
33: * Discovers the services for particular identifier.
34: *
35: * This function attempts to discover and obtain the XRDS document associated
36: * with the identifier, parses the XRDS document and returns an array of
37: * services.
38: *
39: * If an XRDS document is not found, and $openid is set to true, this function
40: * will also attempt to discover OpenID services by looking for link elements
41: * with rel of openid.server or openid2.provider in the discovered HTML document.
42: *
43: * @param string $identifier the identifier
44: * @param bool $openid if true, performs additional discovery of OpenID services
45: * by looking for link elements within the discovered document
46: * @return XRDSServices|null
47: */
48: public function discover($identifier, $openid = FALSE) {
49: $identifier = $this->normalize($identifier);
50: $url = $this->getURL($identifier);
51:
52: $xrds = $this->getXRDSDocument($url);
53:
54: if ($xrds) {
55: return $this->parseXRDS($xrds);
56: } else {
57: if ($openid) return $this->discoverByHTMLLinks($url);
58: return null;
59: }
60: }
61:
62: /**
63: * Obtains the OpenID services for particular identifier by scanning for link
64: * elements in the returned document.
65: *
66: * Note that this function does not use the YADIS protocol to scan for services.
67: * To use the YADIS protocol, use {@link discovery_get_services()}.
68: *
69: * @param string $url the URL
70: * @return XRDSServices an array of discovered services, or an empty array if no services
71: * are found
72: */
73: public function discoverByHTMLLinks($url) {
74: $services = new XRDSServices();
75:
76: $response = $this->request($url);
77: $html = $response->getBody();
78:
79: $uri = $this->getLinkRel('openid2.provider', $html);
80: $delegate = $this->getLinkRel('openid2.local_id', $html);
81:
82: if ($uri) {
83: $service = [
84: 'type' => [ 'http://specs.openid.net/auth/2.0/signon' ],
85: 'uri' => [ $uri ]
86: ];
87: if ($delegate) $service['localid'] = $delegate;
88: $services->add($service, false);
89: }
90:
91: $uri = $this->getLinkRel('openid.server', $html);
92: $delegate = $this->getLinkRel('openid.delegate', $html);
93:
94: if ($uri) {
95: $service = [
96: 'type' => [ 'http://openid.net/signon/1.0' ],
97: 'uri' => [ $uri ]
98: ];
99: if ($delegate) $service['localid'] = $delegate;
100: $services->add($service, false);
101: }
102:
103: return $services;
104: }
105:
106: /**
107: * Obtains a XRDS document at a particular URL. Performs Yadis discovery if
108: * the URL does not produce a XRDS document.
109: *
110: * @param string $url the URL
111: * @param bool $check whether to check the content type of the response is
112: * application/xrds+xml
113: * @param int $retries the number of tries to make
114: * @return string|null the contents of the XRDS document
115: */
116: protected function getXRDSDocument($url, $check = TRUE, $retries = 5) {
117: if ($retries == 0) return NULL;
118:
119: $response = $this->request($url, 'Accept: application/xrds+xml');
120:
121: if ($response->isHTTPError()) return NULL;
122: if (($response->getHeader('Content-Type') == 'application/xrds+xml') || ($check == FALSE)) {
123: return $response->getBody();
124: } elseif ($response->hasHeader('X-XRDS-Location')) {
125: return $this->getXRDSDocument($response->getHeader('X-XRDS-Location'), false, $retries - 1);
126: } else {
127: $location = $this->getMetaHttpEquiv('X-XRDS-Location', $response->getBody());
128: if ($location) {
129: return $this->getXRDSDocument($location, false, $retries - 1);
130: }
131: return NULL;
132: }
133: }
134:
135: /**
136: * Normalises an identifier for discovery.
137: *
138: * If the identifier begins with xri://, acct: or mailto:, this is stripped out. If the identifier
139: * does not begin with a valid URI scheme, http:// is assumed and added to the
140: * identifier.
141: *
142: * @param string $identifier the identifier to normalise
143: * @return string the normalised identifier
144: */
145: protected function normalize($identifier) {
146: $normalized = $identifier;
147:
148: if ($this->isXRI($identifier)) {
149: if (stristr($identifier, 'xri://') !== false) $normalized = substr($identifier, 6);
150: } elseif ($this->isEmail($identifier)) {
151: if (stristr($identifier, 'acct:') !== false) $normalized = substr($identifier, 5);
152: if (stristr($identifier, 'mailto:') !== false) $normalized = substr($identifier, 7);
153: } else {
154: if (stristr($identifier, '://') === false) $normalized = 'http://'. $identifier;
155: if (substr_count($normalized, '/') < 3) $normalized .= '/';
156: }
157:
158: return $normalized;
159: }
160:
161: /**
162: * Obtains a URL for an identifier. If the identifier is a XRI, the XRI resolution
163: * service is used to convert the identifier to a URL.
164: *
165: * @param string $identifier the identifier
166: * @return string the URL
167: */
168: private function getURL($identifier) {
169: if ($this->isXRI($identifier)) {
170: return 'http://xri.net/' . $identifier;
171: } else {
172: return $identifier;
173: }
174:
175: }
176:
177: /**
178: * Determines whether an identifier is an XRI.
179: *
180: * XRI identifiers either start with xri:// or with @, =, +, $ or !.
181: *
182: * @param string $identifier the parameter to test
183: * @return bool true if the identifier is an XRI
184: */
185: private function isXRI($identifier) {
186: $firstchar = substr($identifier, 0, 1);
187: if ($firstchar == "@" || $firstchar == "=" || $firstchar == "+" || $firstchar == "\$" || $firstchar == "!") return true;
188: if (stristr($identifier, 'xri://') !== FALSE) return true;
189: return false;
190: }
191:
192: /**
193: * Determines whether an identifier is an e-mail address.
194: *
195: * An identifier is an e-mail address if it:
196: *
197: * - has a single @ character
198: * - does not have a slash character
199: *
200: * @param string $identifier the parameter to test
201: * @return bool true if the identifier is an e-mail address
202: */
203: private function isEmail($identifier) {
204: // If it begins with acct: or mailto:, strip it out
205: if (stristr($identifier, 'acct:') !== false) $identifier = substr($identifier, 5);
206: if (stristr($identifier, 'mailto:') !== false) $identifier = substr($identifier, 7);
207:
208: // If it contains a slash, it is not an e-mail address
209: if (strpos($identifier, "/") !== false) return false;
210:
211: $at = strpos($identifier, "@");
212:
213: // If it does not contain a @, it is not an e-mail address
214: if ($at === false) return false;
215:
216: // If it contains more than one @, it is not an e-mail
217: if (strrpos($identifier, "@") != $at) return false;
218:
219: return true;
220: }
221:
222: /**
223: * Parses an XRDS document to return services available.
224: *
225: * @param string $xrds the XRDS document
226: * @return XRDSServices the parsed structure
227: *
228: * @see XRDSParser
229: */
230: protected function parseXRDS($xrds) {
231: $parser = new XRDSParser();
232: $parser->load($xrds);
233: $services = $parser->parse();
234: $parser->close();
235:
236: return $services;
237: }
238:
239: /**
240: * Searches through an HTML document to obtain the value of a meta
241: * element with a specified http-equiv attribute.
242: *
243: * @param string $equiv the http-equiv attribute for which to search
244: * @param string $html the HTML document to search
245: * @return mixed the value of the meta element, or FALSE if the element is not
246: * found
247: */
248: protected function getMetaHttpEquiv($equiv, $html) {
249: $html = preg_replace('/<!(?:--(?:[^-]*|-[^-]+)*--\s*)>/', '', $html); // Strip html comments
250:
251: $equiv = preg_quote($equiv);
252: preg_match('|<meta\s+http-equiv=["\']'. $equiv .'["\'](.*)/?>|iUs', $html, $matches);
253: if (isset($matches[1])) {
254: preg_match('|content=["\']([^"]+)["\']|iUs', $matches[1], $content);
255: if (isset($content[1])) {
256: return $content[1];
257: }
258: }
259: return FALSE;
260: }
261:
262: /**
263: * Searches through an HTML document to obtain the value of a link
264: * element with a specified rel attribute.
265: *
266: * @param string $rel the rel attribute for which to search
267: * @param string $html the HTML document to search
268: * @return mixed the href of the link element, or FALSE if the element is not
269: * found
270: */
271: protected function getLinkRel($rel, $html) {
272: $html = preg_replace('/<!(?:--(?:[^-]*|-[^-]+)*--\s*)>/s', '', $html); // Strip html comments
273:
274: $rel = preg_quote($rel);
275: preg_match('|<link\s+rel=["\'](.*)'. $rel .'(.*)["\'](.*)/?>|iUs', $html, $matches);
276: if (isset($matches[3])) {
277: preg_match('|href=["\']([^"]+)["\']|iU', $matches[3], $href, PREG_UNMATCHED_AS_NULL);
278: if (isset($href[1])) return trim($href[1]);
279: }
280: return FALSE;
281: }
282:
283: /**
284: * Performs an HTTP request.
285: *
286: * Communication with the web server is conducted using libcurl where possible.
287: * Where libcurl does not exist, then sockets will be used.
288: *
289: * Note that the request must be properly prepared before passing onto this function.
290: * For example, for POST requests, the Content-Type and Content-Length headers must be
291: * included in $headers.
292: *
293: * @param string $url the URL
294: * @param array<string>|string $headers HTTP headers containing name => value pairs
295: * @return HTTPResponse
296: */
297: protected function request($url, $headers = '') {
298: $web = \Web::instance();
299: $result = $web->request($url, [ 'header' => $headers ]);
300: return new HTTPResponse($result);
301: }
302: }
303:
304: ?>
305: