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\WebFinger;
24:
25: use Psr\Log\LogLevel;
26: use SimpleID\Module;
27: use SimpleID\Store\StoreManager;
28: use SimpleID\Util\RateLimiter;
29:
30: /**
31: * A module implementing the WebFinger protocol for accessing user
32: * information.
33: *
34: * @see http://tools.ietf.org/html/rfc7033
35: * @since 2.0
36: */
37: class WebFingerModule extends Module {
38: static function init($f3) {
39: $f3->route('GET|HEAD /.well-known/webfinger', 'SimpleID\Protocols\WebFinger\WebFingerModule->start');
40: }
41:
42: /**
43: * @return void
44: */
45: function start() {
46: $config = $this->f3->get('config');
47: $limiter = new RateLimiter('webfinger');
48:
49: if (!$limiter->throttle()) {
50: header('Retry-After: ' . $limiter->getInterval());
51: // We never display a log for rate limit errors
52: $this->fatalError($this->f3->get('intl.common.ratelimit_error'), 429);
53: }
54:
55: $this->logger->log(LogLevel::INFO, 'SimpleID\Protocols\WebFinger->start');
56:
57: if (!$this->f3->exists('GET.resource') || ($this->f3->get('GET.resource') == '')) {
58: $this->logger->log(LogLevel::NOTICE, 'resource parameter missing or empty');
59: $this->fatalError($this->f3->get('intl.core.webfinger.missing_resource'), 400);
60: return;
61: }
62:
63: $resource = $this->f3->get('GET.resource');
64: $this->logger->log(LogLevel::INFO, 'Requested resource URI: ' . $resource);
65:
66: $jrd = $this->getJRD($resource);
67:
68: if ($jrd == NULL) {
69: $limiter->penalize(); // Stop $remote_addr from querying again
70: $this->fatalError($this->f3->get('intl.common.not_found'), 404);
71: return;
72: }
73:
74: $jrd = $this->fixJRDAliases($jrd, $resource);
75:
76: if (isset($_GET['rel'])) $jrd = $this->filterJRDRels($jrd, $_GET['rel']);
77:
78: header('Content-Type: application/jrd+json');
79: header('Content-Disposition: inline; filename=webfinger.json');
80: header('Access-Control-Allow-Origin: ' . $config['webfinger_access_control_allow_origin']);
81:
82: if ($this->f3->get('VERB') == 'HEAD') return;
83:
84: print json_encode($jrd);
85: }
86:
87: /**
88: * Creates a JRD document based on a SimpleID user.
89: *
90: * The JRD document created is very simple - it merely points to the
91: * SimpleID installation as the OpenID connect provider.
92: *
93: * @param string $resource the resource identifier
94: * @return array<string, mixed>|null the JRD document
95: */
96: protected function getJRD($resource) {
97: $store = StoreManager::instance();
98:
99: $criteria = $this->getResourceCriteria($resource);
100: if ($criteria == null) return null;
101:
102: foreach ($criteria as $criterion => $value) {
103: /** @var \SimpleID\Models\User $user */
104: $user = $store->findUser($criterion, $value);
105: if ($user != null) break;
106: }
107: if ($user == null) return null;
108:
109: $jrd = [
110: 'subject' => $user['identity'],
111: 'links' => [
112: [
113: 'rel' => 'http://specs.openid.net/auth/2.0/provider',
114: 'href' => rtrim($this->f3->get('config.canonical_base_path'), '/')
115: ],
116: [
117: 'rel' => 'http://openid.net/specs/connect/1.0/issuer',
118: 'href' => $this->getCanonicalHost()
119: ]
120: ]
121: ];
122:
123: if (isset($user['aliases'])) {
124: if (is_array($user['aliases'])) {
125: $jrd['aliases'] = $user['aliases'];
126: } else {
127: $jrd['aliases'] = [ $user['aliases'] ];
128: }
129: }
130:
131: return $jrd;
132: }
133:
134: /**
135: * Obtains the criteria to search, based on a specified resource
136: * identifier.
137: *
138: * This function works out the type of resource being requested (e.g.
139: * URL or e-mail), then supplies the appropriate path(s) to search
140: * for.
141: * @param string $resource the resource identifier
142: * @return array<string, string>|null an array of criteria paths and their corresponding
143: * values
144: */
145: protected function getResourceCriteria($resource) {
146: $audit = \Audit::instance();
147:
148: if ($audit->url($resource)) return [ 'openid.identity' => $resource ];
149:
150: // If it begins with acct: or mailto:, strip it out
151: if ((stristr($resource, 'acct:') !== false) || (stristr($resource, 'mailto:') !== false)) {
152: list (, $email) = explode(':', $resource, 2);
153: if ($audit->email($email)) {
154: return [ 'webfinger.acct' => $email, 'userinfo.email' => $email ];
155: }
156: }
157:
158: return null;
159: }
160:
161: /**
162: * Ensures that a specified resource URI occurs in either the subject or
163: * the aliases member of a JRD document.
164: *
165: * @param array<string, mixed> $jrd the JRD document
166: * @param string $resource the resource URI
167: * @return array<string, mixed> the fixed JRD document
168: */
169: protected function fixJRDAliases($jrd, $resource) {
170: if (isset($jrd['subject']) && ($jrd['subject'] == $resource)) return $jrd;
171:
172: if (isset($jrd['aliases'])) {
173: $found = FALSE;
174: foreach ($jrd['aliases'] as $alias) {
175: if ($alias == $resource) {
176: $found = TRUE;
177: break;
178: }
179: }
180: if (!$found) $jrd['aliases'][] = $resource;
181: } else {
182: $jrd['aliases'] = [ $resource ];
183: }
184: return $jrd;
185: }
186:
187: /**
188: * Filters a JRD document for specified link relations.
189: *
190: * @param array<string, mixed> $jrd the JRD document
191: * @param string|array<string> $rels a string contain a link relation, or an array containing
192: * multiple link relations, to filter
193: * @return array<string, mixed> the filtered JRD document
194: */
195: protected function filterJRDRels($jrd, $rels) {
196: if (isset($jrd['links'])) {
197: if (!is_array($rels)) $rels = [ $rels ];
198:
199: $links = $jrd['links'];
200: $filtered_links = [];
201:
202: foreach ($links as $link) {
203: if (isset($link['rel']) && in_array($link['rel'], $rels)) {
204: $filtered_links[] = $link;
205: }
206: }
207:
208: $jrd['links'] = $filtered_links;
209: }
210: return $jrd;
211: }
212: }
213:
214:
215: ?>
216: