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\OpenID;
24:
25: use Psr\Log\LogLevel;
26: use SimpleID\Module;
27: use SimpleID\ModuleManager;
28: use SimpleID\Base\RouteContentNegotiationEvent;
29: use SimpleID\Base\ScopeInfoCollectionEvent;
30: use SimpleID\Base\RequestState;
31: use SimpleID\Auth\AuthManager;
32: use SimpleID\Crypt\Random;
33: use SimpleID\Crypt\SecurityToken;
34: use SimpleID\Protocols\ProtocolResult;
35: use SimpleID\Protocols\ProtocolResultEvent;
36: use SimpleID\Store\StoreManager;
37: use SimpleID\Util\Events\BaseDataCollectionEvent;
38: use SimpleID\Util\Events\UIBuildEvent;
39: use SimpleID\Util\Forms\FormBuildEvent;
40: use SimpleID\Util\Forms\FormSubmitEvent;
41: use SimpleID\Util\Forms\FormState;
42: use SimpleID\Util\UI\Template;
43:
44: /**
45: * The module for authentication under OpenID version 1.1 and 2.0
46: */
47: class OpenIDModule extends Module implements ProtocolResult {
48:
49: /** Constant for the XRDS service type for return_to verification */
50: const OPENID_RETURN_TO = 'http://specs.openid.net/auth/2.0/return_to';
51:
52: const DEFAULT_SCOPE = 'tag:simpleid.sf.net,2021:openid:default';
53:
54: /** @var \Cache */
55: protected $cache;
56:
57: /** @var ModuleManager */
58: protected $mgr;
59:
60: static function init($f3) {
61: $f3->route('GET @openid_provider_xrds: /openid/xrds', 'SimpleID\Protocols\OpenID\OpenIDModule->providerXRDS');
62: $f3->route('GET @openid_user_xrds: /user/@uid/xrds', 'SimpleID\Protocols\OpenID\OpenIDModule->userXRDS');
63: $f3->route('POST @openid_consent: /openid/consent', 'SimpleID\Protocols\OpenID\OpenIDModule->consent');
64: }
65:
66: function __construct() {
67: parent::__construct();
68: $this->cache = \Cache::instance();
69: $this->mgr = ModuleManager::instance();
70: }
71:
72: /**
73: * @return void
74: */
75: public function onRouteContentNegotiationEvent(RouteContentNegotiationEvent $event) {
76: $route = $event->getRoute();
77: $content_type = $event->negotiate([ 'text/html', 'application/xml', 'application/xhtml+xml', 'application/xrds+xml' ]);
78:
79: switch ($route) {
80: case 'index':
81: $_request = $event->getRequest();
82:
83: if (isset($_request['openid.mode'])) {
84: $this->start(new Request($_request));
85: $event->stopPropagation();
86: } elseif ($content_type == 'application/xrds+xml') {
87: $this->providerXRDS();
88: $event->stopPropagation();
89: } else {
90: // Point to SimpleID's XRDS document
91: header('X-XRDS-Location: ' . $this->getCanonicalURL('@openid_provider_xrds'));
92: return;
93: }
94: break;
95:
96: case 'user':
97: if ($content_type == 'application/xrds+xml') {
98: $this->userXRDS();
99: $event->stopPropagation();
100: }
101: break;
102: }
103: }
104:
105: /**
106: * Process an OpenID request under versions 1 and 2.
107: *
108: * This function determines the version of the OpenID specification that is
109: * relevant to this request, checks openid.mode and passes the
110: * request on to the function required to process the request.
111: *
112: * The OpenID request expressed as an array contain key-value pairs corresponding
113: * to the HTTP request. This is usually contained in the <code>$_REQUEST</code>
114: * variable.
115: *
116: * @param Request $request the OpenID request
117: * @return void
118: */
119: public function start($request) {
120: switch ($request['openid.mode']) {
121: case 'associate':
122: $this->associate($request);
123: break;
124: case 'checkid_immediate':
125: case 'checkid_setup':
126: $token = new SecurityToken();
127: $request_state = new RequestState();
128: $request_state->setParams($request->toArray());
129: $this->checkHttps('redirect', true, $this->getCanonicalURL('continue/' . rawurlencode($token->generate($request_state)), '', 'https'));
130:
131: $this->checkid($request);
132: break;
133: case 'check_authentication':
134: $this->check_authentication($request);
135: break;
136: default:
137: if (isset($request['openid.return_to'])) {
138: // Indirect communication - send error via indirect communication.
139: $this->fatalError($this->f3->get('intl.core.openid.invalid_message'), 400);
140: } else {
141: // Direct communication
142: $this->directError('Invalid OpenID message.', [], $request);
143: }
144: }
145: }
146:
147: /**
148: * Processes an association request from a relying party under OpenID versions
149: * 1 and 2.
150: *
151: * An association request has an openid.mode value of
152: * associate. This function checks whether the association request
153: * is valid, and if so, creates an association and sends the response to
154: * the relying party.
155: *
156: * @param Request $request the OpenID request
157: * @return void
158: * @link http://openid.net/specs/openid-authentication-1_1.html#mode_associate, http://openid.net/specs/openid-authentication-2_0.html#associations
159: */
160: protected function associate($request) {
161: $this->logger->log(LogLevel::DEBUG, 'SimpleID\Protocols\OpenID\OpenIDModule->associate');
162: $this->logger->log(LogLevel::INFO, 'OpenID association request', $request->toArray());
163:
164: $assoc_types = Association::getAssociationTypes();
165: $session_types = Association::getSessionTypes($this->isHttps(), $request->getVersion());
166:
167: // Common Request Parameters [8.1.1]
168: if (($request->getVersion() == Message::OPENID_VERSION_1_1) && !isset($request['openid.session_type'])) $request['openid.session_type'] = '';
169: $assoc_type = $request['openid.assoc_type'];
170: $session_type = $request['openid.session_type'];
171:
172: // Diffie-Hellman Request Parameters [8.1.2]
173: $dh_modulus = (isset($request['openid.dh_modulus'])) ? $request['openid.dh_modulus'] : NULL;
174: $dh_gen = (isset($request['openid.dh_gen'])) ? $request['openid.dh_gen'] : NULL;
175: $dh_consumer_public = $request['openid.dh_consumer_public'];
176:
177: if (!isset($request['openid.session_type']) || !isset($request['openid.assoc_type'])) {
178: $this->logger->log(LogLevel::ERROR, 'Association failed: openid.session_type or openid.assoc_type not set');
179: $this->directError('openid.session_type or openid.assoc_type not set', [], $request);
180: return;
181: }
182:
183: // Check if the assoc_type is supported
184: if (!array_key_exists($assoc_type, $assoc_types)) {
185: $error = [
186: 'error_code' => 'unsupported-type',
187: 'session_type' => 'DH-SHA1',
188: 'assoc_type' => 'HMAC-SHA1'
189: ];
190: $this->logger->log(LogLevel::ERROR, 'Association failed: The association type is not supported by SimpleID.');
191: $this->directError('The association type is not supported by SimpleID.', $error, $request);
192: return;
193: }
194: // Check if the session_type is supported
195: if (!array_key_exists($session_type, $session_types)) {
196: $error = [
197: 'error_code' => 'unsupported-type',
198: 'session_type' => 'DH-SHA1',
199: 'assoc_type' => 'HMAC-SHA1'
200: ];
201: $this->logger->log(LogLevel::ERROR, 'Association failed: The session type is not supported by SimpleID.');
202: $this->directError('The session type is not supported by SimpleID.', $error, $request);
203: return;
204: }
205:
206: if ($session_type == 'DH-SHA1' || $session_type == 'DH-SHA256') {
207: if (!$dh_consumer_public) {
208: $this->logger->log(LogLevel::ERROR, 'Association failed: openid.dh_consumer_public not set');
209: $this->directError('openid.dh_consumer_public not set', [], $request);
210: return;
211: }
212: }
213:
214: $association = new Association(Association::ASSOCIATION_SHARED, $assoc_type);
215: $this->logger->log(LogLevel::INFO, 'Created association: ' . $association->toString());
216:
217: $this->cache->set($association->getHandle() . '.openid_association', $association, SIMPLEID_SHORT_TOKEN_EXPIRES_IN);
218:
219: $response = new Response($request);
220: $response->setArray($association->getOpenIDResponse($session_type, $dh_consumer_public, $dh_modulus, $dh_gen));
221: $response->set('expires_in', strval(SIMPLEID_SHORT_TOKEN_EXPIRES_IN));
222: $this->logger->log(LogLevel::INFO, 'Association response', $response->toArray());
223:
224: $response->render();
225: }
226:
227: /**
228: * Processes an authentication request from a relying party.
229: *
230: * An authentication request has an openid.mode value of
231: * checkid_setup or checkid_immediate.
232: *
233: * If the authentication request is a standard OpenID request about an identity
234: * (i.e. contains the key openid.identity), this function calls
235: * {@link simpleid_checkid_identity()} to see whether the user logged on into SimpleID
236: * matches the identity supplied in the OpenID request.
237: *
238: * If the authentication request is not about an identity, this function dispatches
239: * a {@link OpenIDCheckEvent}, which can be listened to by other extension
240: * modules.
241: *
242: * Depending on the OpenID version, this function will supply an appropriate
243: * assertion.
244: *
245: * @param Request $request the OpenID request
246: * @return void
247: */
248: public function checkid($request) {
249: $immediate = ($request['openid.mode'] == 'checkid_immediate');
250: $version = $request->getVersion();
251:
252: $this->logger->log(LogLevel::INFO, 'OpenID authentication request: ' . (($immediate) ? 'immediate' : 'setup') . '; ', $request->toArray());
253:
254: // Check for protocol correctness
255: if ($version == Message::OPENID_VERSION_1_1) {
256: if (!isset($request['openid.return_to'])) {
257: $this->logger->log(LogLevel::ERROR, 'Protocol Error: openid.return_to not set.');
258: $this->fatalError($this->f3->get('intl.core.openid.missing_return_to'), 400);
259: return;
260: }
261: if (!isset($request['openid.identity'])) {
262: $this->logger->log(LogLevel::ERROR, 'Protocol Error: openid.identity not set.');
263: $this->fatalError($this->f3->get('intl.core.openid.missing_identity'), 400);
264: return;
265: }
266: }
267:
268: if ($version == Message::OPENID_VERSION_2) {
269: if (isset($request['openid.identity']) && !isset($request['openid.claimed_id'])) {
270: $this->logger->log(LogLevel::ERROR, 'Protocol Error: openid.identity set, but not openid.claimed_id.');
271: $this->fatalError($this->f3->get('intl.core.openid.missing_claimed_id'), 400);
272: return;
273: }
274:
275: if (!isset($request['openid.realm']) && !isset($request['openid.return_to'])) {
276: $this->logger->log(LogLevel::ERROR, 'Protocol Error: openid.return_to not set when openid.realm is not set.');
277: $this->fatalError($this->f3->get('intl.core.openid.realm_but_no_return_to'), 400);
278: return;
279: }
280: }
281:
282: if (isset($request['openid.return_to'])) {
283: $realm = $request->getRealm();
284:
285: if (!$request->returnToMatches($realm)) {
286: $this->logger->log(LogLevel::ERROR, 'Protocol Error: openid.return_to does not match realm.');
287: $this->indirectError($request['openid.return_to'], 'Protocol Error: openid.return_to does not match realm.', [], $request);
288: return;
289: }
290: }
291:
292: if (isset($request['openid.identity'])) {
293: // Standard request
294: $this->logger->log(LogLevel::DEBUG, 'openid.identity found, use openIDCheckIdentity');
295: $result = $this->openIDCheckIdentity($request, $immediate);
296: } else {
297: $this->logger->log(LogLevel::DEBUG, 'openid.identity not found, trying extensions');
298:
299: // Extension request
300: $event = new OpenIDCheckEvent($request, $immediate);
301: \Events::instance()->dispatch($event);
302:
303: $result = $event->getResult();
304: }
305:
306: switch ($result) {
307: case self::CHECKID_APPROVAL_REQUIRED:
308: $this->logger->log(LogLevel::INFO, 'CHECKID_APPROVAL_REQUIRED');
309: if ($immediate) {
310: $response = $this->createApprovalRequiredResponse($request);
311: $response->render($request['openid.return_to']);
312: } else {
313: $response = $this->createOKResponse($request);
314: $this->consentForm($request, $response, $result);
315: }
316: break;
317: case self::CHECKID_RETURN_TO_SUSPECT:
318: $this->logger->log(LogLevel::INFO, 'CHECKID_RETURN_TO_SUSPECT');
319: if ($immediate) {
320: $response = $this->createErrorResponse($request, $immediate);
321: $response->render($request['openid.return_to']);
322: } else {
323: $response = $this->createOKResponse($request);
324: $this->consentForm($request, $response, $result);
325: }
326: break;
327: case self::CHECKID_OK:
328: $this->logger->log(LogLevel::INFO, 'CHECKID_OK');
329: $this->logActivity($request);
330: $response = $this->createOKResponse($request);
331: $this->signResponse($response, isset($request['openid.assoc_handle']) ? $request['openid.assoc_handle'] : NULL);
332: $response->render($request['openid.return_to']);
333: break;
334: case self::CHECKID_REENTER_CREDENTIALS:
335: case self::CHECKID_LOGIN_REQUIRED:
336: $this->logger->log(LogLevel::INFO, 'CHECKID_REENTER_CREDENTIALS | CHECKID_LOGIN_REQUIRED');
337: if ($immediate) {
338: $response = $this->createLoginRequiredResponse($request, $result);
339: $response->render($request['openid.return_to']);
340: } else {
341: $token = new SecurityToken();
342: $request_state = new RequestState();
343: $request_state->setParams($request->toArray());
344: $form_state = new FormState([
345: 'cancel' => 'openid',
346: 'mode' => AuthManager::MODE_CREDENTIALS,
347: 'auth_skip_activity' => true
348: ]);
349: $form_state->setRequest($request);
350: if ($result == self::CHECKID_REENTER_CREDENTIALS) {
351: $auth = AuthManager::instance();
352: $user = $auth->getUser();
353: $form_state['uid'] = $user['uid'];
354: $form_state['mode'] = AuthManager::MODE_REENTER_CREDENTIALS;
355: }
356:
357: /** @var \SimpleID\Auth\AuthModule */
358: $auth_module = $this->mgr->getModule('SimpleID\Auth\AuthModule');
359: $auth_module->loginForm([
360: 'destination' => 'continue/' . rawurlencode($token->generate($request_state))
361: ], $form_state);
362: exit;
363: }
364: break;
365: case self::CHECKID_IDENTITIES_NOT_MATCHING:
366: case self::CHECKID_IDENTITY_NOT_EXIST:
367: $this->logger->log(LogLevel::INFO, 'CHECKID_IDENTITIES_NOT_MATCHING | CHECKID_IDENTITY_NOT_EXIST');
368: $response = $this->createErrorResponse($request, $immediate);
369: if ($immediate) {
370: $response->render($request['openid.return_to']);
371: } else {
372: $this->consentForm($request, $response, $result);
373: }
374: break;
375: case self::CHECKID_PROTOCOL_ERROR:
376: if (isset($request['openid.return_to'])) {
377: $response = $this->createErrorResponse($request, $immediate);
378: $response->render($request['openid.return_to']);
379: } else {
380: $this->fatalError('Unrecognised request.', 400);
381: }
382: break;
383: }
384: }
385:
386: /**
387: * Processes a standard OpenID authentication request about an identity.
388: *
389: * Checks whether the current user logged into SimpleID matches the identity
390: * supplied in an OpenID request.
391: *
392: * @param Request $request the OpenID request
393: * @param bool $immediate whether checkid_immediate was used
394: * @return int one of CHECKID_OK, CHECKID_APPROVAL_REQUIRED, CHECKID_RETURN_TO_SUSPECT, CHECKID_IDENTITY_NOT_EXIST,
395: * CHECKID_IDENTITIES_NOT_MATCHING, CHECKID_LOGIN_REQUIRED or CHECKID_PROTOCOL_ERROR
396: */
397: protected function openIDCheckIdentity($request, $immediate) {
398: $auth = AuthManager::instance();
399: $store = StoreManager::instance();
400: $config = $this->f3->get('config');
401:
402: $realm = $request->getRealm();
403: $cid = RelyingParty::buildID($realm);
404:
405: // Check 1: Is the user logged into SimpleID as any user?
406: if (!$auth->isLoggedIn()) {
407: return self::CHECKID_LOGIN_REQUIRED;
408: } else {
409: $user = $auth->getUser();
410: $uid = $user['uid'];
411: }
412:
413: // Check 2: Is the user logged in as the same identity as the identity requested?
414: // Choose the identity URL for the user automatically
415: if ($request['openid.identity'] == Request::OPENID_IDENTIFIER_SELECT) {
416: $test_user = $store->loadUser($uid);
417: $identity = $test_user['openid']['identity'];
418:
419: $this->logger->log(LogLevel::INFO, 'OpenID identifier selection: Selected ' . $uid . ' [' . $identity . ']');
420: } else {
421: $identity = $request['openid.identity'];
422: /** @var \SimpleID\Models\User $test_user */
423: $test_user = $store->findUser('openid.identity', $identity);
424: }
425: if ($test_user == NULL) return self::CHECKID_IDENTITY_NOT_EXIST;
426: if ($test_user['uid'] != $user['uid']) {
427: $this->logger->log(LogLevel::NOTICE, 'Requested user ' . $test_user['uid'] . ' does not match logged in user ' . $user['uid']);
428: return self::CHECKID_IDENTITIES_NOT_MATCHING;
429: }
430:
431: // Pass the assertion to extensions
432: $event = new OpenIDCheckEvent($request, $immediate, $identity);
433: \Events::instance()->dispatch($event);
434:
435: // Populate the request with the selected identity
436: if ($request['openid.identity'] == Request::OPENID_IDENTIFIER_SELECT) {
437: $request['openid.claimed_id'] = $identity;
438: $request['openid.identity'] = $identity;
439: }
440:
441: // Check 3: Discover the realm and match its return_to
442: $client_prefs = (isset($user->clients[$cid])) ? $user->clients[$cid] : NULL;
443:
444: if (($request->getVersion() == Message::OPENID_VERSION_2) && $config['openid_verify_return_url']) {
445: $verified = FALSE;
446:
447: $relying_party = $this->loadRelyingParty($realm);
448: if ($relying_party->getServices() == null) {
449: $services = [];
450: } else {
451: $services = $relying_party->getServices()->getByType(self::OPENID_RETURN_TO);
452: }
453:
454: $this->logger->log(LogLevel::INFO, 'OpenID 2 discovery: ' . count($services) . ' matching services');
455:
456: if ($services) {
457: $return_to_uris = [];
458:
459: foreach ($services as $service) {
460: $return_to_uris = array_merge($return_to_uris, $service['uri']);
461: }
462: foreach ($return_to_uris as $return_to) {
463: if ($request->returnToMatches($return_to, $config['openid_strict_realm_check'])) {
464: $this->logger->log(LogLevel::INFO, 'OpenID 2 discovery: verified');
465: $verified = TRUE;
466: break;
467: }
468: }
469: }
470:
471: if (!$verified) {
472: if (($client_prefs != NULL) && isset($client_prefs['consents']['openid']) && $client_prefs['consents']['openid']) {
473: $this->logger->log(LogLevel::NOTICE, 'OpenID 2 discovery: not verified, but overridden by user preference');
474: } else {
475: $this->logger->log(LogLevel::NOTICE, 'OpenID 2 discovery: not verified');
476: $event->setResult(self::CHECKID_RETURN_TO_SUSPECT);
477: }
478: }
479: }
480:
481: // Check 4: For checkid_immediate, the user must already have given
482: // permission to log in automatically.
483: if (($client_prefs != NULL) && isset($client_prefs['consents']['openid']) && $client_prefs['consents']['openid']) {
484: $this->logger->log(LogLevel::INFO, 'Automatic set for realm ' . $realm);
485: $event->setResult(self::CHECKID_OK);
486:
487: $final_assertion_result = $event->getResult();
488:
489: if ($final_assertion_result == self::CHECKID_OK) {
490: if (!isset($user->clients[$cid])) $user->clients[$cid] = [];
491: $user->clients[$cid]['last_time'] = time();
492: $store->saveUser($user);
493: }
494:
495: return $final_assertion_result;
496: } else {
497: $event->setResult(self::CHECKID_APPROVAL_REQUIRED);
498: return $event->getResult();
499: }
500: }
501:
502: /**
503: * Returns an OpenID response indicating a positive assertion.
504: *
505: * @param Request $request the OpenID request
506: * @return Response an OpenID response with a positive assertion
507: * @link http://openid.net/specs/openid-authentication-1_1.html#anchor17, http://openid.net/specs/openid-authentication-1_1.html#anchor23, http://openid.net/specs/openid-authentication-2_0.html#positive_assertions
508: */
509: protected function createOKResponse($request) {
510: $rand = new Random();
511: $nonce = gmdate('Y-m-d\TH:i:s\Z') . bin2hex($rand->bytes(4));
512:
513: $response = new Response($request);
514: $response->setArray([
515: 'mode' => 'id_res',
516: 'op_endpoint' => $this->getCanonicalURL(),
517: 'response_nonce' => $nonce
518: ]);
519:
520: if (isset($request['openid.assoc_handle'])) $response['assoc_handle'] = $request['openid.assoc_handle'];
521: if (isset($request['openid.identity'])) $response['identity'] = $request['openid.identity'];
522: if (isset($request['openid.return_to'])) $response['return_to'] = $request['openid.return_to'];
523:
524: if (($request->getVersion() == Message::OPENID_VERSION_2) && isset($request['openid.claimed_id'])) {
525: $response['claimed_id'] = $request['openid.claimed_id'];
526: }
527:
528: $event = new OpenIDResponseBuildEvent(true, $request, $response);
529: \Events::instance()->dispatch($event);
530:
531: $this->logger->log(LogLevel::INFO, 'OpenID authentication response', $event->getResponse()->toArray());
532: return $event->getResponse();
533: }
534:
535: /**
536: * Returns an OpenID response indicating a negative assertion to a
537: * checkid_immediate request, where an approval of the relying party by the
538: * user is required
539: *
540: * @param Request $request the OpenID request
541: * @return Response an OpenID response with a negative assertion
542: * @link http://openid.net/specs/openid-authentication-1_1.html#anchor17, http://openid.net/specs/openid-authentication-1_1.html#anchor23, http://openid.net/specs/openid-authentication-2_0.html#negative_assertions
543: */
544: protected function createApprovalRequiredResponse($request) {
545: $response = new Response($request);
546:
547: if ($request->getVersion() == Message::OPENID_VERSION_2) {
548: $response['mode'] = 'setup_needed';
549: } else {
550: $token = new SecurityToken();
551: $request_state = new RequestState();
552: $request_state->setParams($request->toArray());
553:
554: $request['openid.mode'] = 'checkid_setup';
555: $response->setArray([
556: 'mode' => 'id_res',
557: 'user_setup_url' => $this->getCanonicalURL('auth/login/continue/' . rawurlencode($token->generate($request_state)))
558: ]);
559: }
560:
561: $event = new OpenIDResponseBuildEvent(false, $request, $response);
562: \Events::instance()->dispatch($event);
563:
564: $this->logger->log(LogLevel::INFO, 'OpenID authentication response', $event->getResponse()->toArray());
565: return $event->getResponse();
566: }
567:
568: /**
569: * Returns an OpenID response indicating a negative assertion to a
570: * checkid_immediate request, where the user has not logged in.
571: *
572: * @param Request $request the OpenID request
573: * @param int $result the authentication result providing the negative
574: * assertion
575: * @return Response an OpenID response with a negative assertion
576: * @link http://openid.net/specs/openid-authentication-1_1.html#anchor17, http://openid.net/specs/openid-authentication-1_1.html#anchor23, http://openid.net/specs/openid-authentication-2_0.html#negative_assertions
577: */
578: protected function createLoginRequiredResponse($request, $result = self::CHECKID_LOGIN_REQUIRED) {
579: $response = new Response($request);
580:
581: if ($request->getVersion() == Message::OPENID_VERSION_2) {
582: $response['mode'] = 'setup_needed';
583: } else {
584: $token = new SecurityToken();
585: $request_state = new RequestState();
586: $request_state->setParams($request->toArray());
587: $query = ($result == self::CHECKID_REENTER_CREDENTIALS) ? 'mode=' . AuthManager::MODE_REENTER_CREDENTIALS : '';
588:
589: $response->setArray([
590: 'mode' => 'id_res',
591: 'user_setup_url' => $this->getCanonicalURL('auth/login/continue/' . rawurlencode($token->generate($request_state)), $query)
592: ]);
593: }
594:
595: $event = new OpenIDResponseBuildEvent(false, $request, $response);
596: \Events::instance()->dispatch($event);
597:
598: $this->logger->log(LogLevel::INFO, 'OpenID authentication response', $event->getResponse()->toArray());
599: return $event->getResponse();
600: }
601:
602: /**
603: * Returns an OpenID response indicating a generic negative assertion.
604: *
605: * The content of the negative version depends on the OpenID version, and whether
606: * the openid.mode of the request was checkid_immediate
607: *
608: * @param Request $request the OpenID request
609: * @param bool $immediate true if openid.mode of the request was checkid_immediate
610: * @return Response an OpenID response with a negative assertion
611: * @link http://openid.net/specs/openid-authentication-1_1.html#anchor17, http://openid.net/specs/openid-authentication-1_1.html#anchor23, http://openid.net/specs/openid-authentication-2_0.html#negative_assertions
612: */
613: protected function createErrorResponse($request, $immediate = false) {
614: $response = new Response($request);
615: $version = $request->getVersion();
616:
617: if ($immediate) {
618: if ($version == Message::OPENID_VERSION_2) {
619: $response['mode'] = 'setup_needed';
620: } else {
621: $response['mode'] = 'id_res';
622: }
623: } else {
624: $response['mode'] = 'cancel';
625: }
626:
627: $event = new OpenIDResponseBuildEvent(false, $request, $response);
628: \Events::instance()->dispatch($event);
629:
630: $this->logger->log(LogLevel::INFO, 'OpenID authentication response', $event->getResponse()->toArray());
631: return $event->getResponse();
632: }
633:
634: /**
635: * Signs an OpenID response, using signature information from an association
636: * handle.
637: *
638: * @param Response $response the OpenID response
639: * @param string $assoc_handle the association handle containing key information
640: * for the signature. If $assoc_handle is not specified, a private association
641: * is created
642: * @return Response the signed OpenID response
643: */
644: protected function signResponse($response, $assoc_handle = NULL) {
645: $cache = \Cache::instance();
646:
647: if (!$assoc_handle) {
648: $assoc = new Association(Association::ASSOCIATION_PRIVATE);
649: $response['assoc_handle'] = $assoc->getHandle();
650: } else {
651: $assoc = $cache->get(rawurlencode($assoc_handle) . '.openid_association');
652:
653: if ($assoc->getCreationTime() + SIMPLEID_SHORT_TOKEN_EXPIRES_IN < time()) {
654: // Association has expired, need to create a new one
655: $this->logger->log(LogLevel::NOTICE, 'Association handle ' . $assoc->getHandle() . ' expired. Using stateless mode.');
656: $response['invalidate_handle'] = $assoc_handle;
657: $assoc = new Association(Association::ASSOCIATION_PRIVATE);
658: $response['assoc_handle'] = $assoc->getHandle();
659: }
660: }
661:
662: // If we are using stateless mode, then we need to cache the response_nonce
663: // so that the RP can only verify once
664: if ($assoc->isPrivate() && isset($response['response_nonce'])) {
665: $cache->set(rawurlencode($response['response_nonce']) . '.openid_response' , [
666: 'response_nonce' => $response['response_nonce'],
667: 'association' => $assoc
668: ], SIMPLEID_SHORT_TOKEN_EXPIRES_IN);
669: }
670:
671: $response['sig'] = $assoc->sign($response);
672:
673: $this->logger->log(LogLevel::INFO, 'OpenID signed authentication response', $response->toArray());
674:
675: return $response;
676: }
677:
678: /**
679: * Processes a direct verification request. This is used in the OpenID specification
680: * to verify signatures generated using stateless mode.
681: *
682: * @param Request $request the OpenID request
683: * @return void
684: * @see http://openid.net/specs/openid-authentication-1_1.html#mode_check_authentication, http://openid.net/specs/openid-authentication-2_0.html#verifying_signatures
685: */
686: protected function check_authentication($request) {
687: $this->logger->log(LogLevel::DEBUG, 'SimpleID\Protocols\OpenID\OpenIDModule->check_authentication');
688: $this->logger->log(LogLevel::INFO, 'OpenID direct verification', $request->toArray());
689:
690: $cache = \Cache::instance();
691:
692: $response = new Response($request);
693: $response['is_valid'] = ($this->verifySignatures($request)) ? 'true' : 'false';
694:
695: // RP wants to check whether a handle is invalid
696: if (isset($request['openid.invalidate_handle'])) {
697: $invalid_assoc = $cache->get(rawurlencode($request['openid.invalidate_handle']) . 'openid_association');
698:
699: if (!$invalid_assoc || ($invalid_assoc->getCreationTime() + SIMPLEID_SHORT_TOKEN_EXPIRES_IN < time())) {
700: // Yes, it's invalid
701: $response['invalidate_handle'] = $request['openid.invalidate_handle'];
702: }
703: }
704:
705: $this->logger->log(LogLevel::INFO, 'OpenID direct verification response', $response->toArray());
706:
707: $response->render();
708: }
709:
710: /**
711: * Verifies the signature of a signed OpenID request/response.
712: *
713: * @param Request $request the OpenID request/response
714: * @return bool true if the signature is verified
715: * @since 0.8
716: */
717: protected function verifySignatures($request) {
718: $cache = \Cache::instance();
719:
720: // rawurlencode is used to ensure potentially dangerous input is made safe
721: $stateless = (isset($request['openid.response_nonce'])) ? $cache->get(rawurlencode($request['openid.response_nonce']) . '.openid_response') : NULL;
722: if ($stateless == NULL) {
723: $this->logger->log(LogLevel::NOTICE, 'Response nonce not found: ' . $request['openid.response_nonce']);
724: return false;
725: }
726: $cache->clear(rawurlencode($request['openid.response_nonce']) . '.openid_response');
727:
728: $association = $stateless['association'];
729:
730: if (!$association->isPrivate()) {
731: $this->logger->log(LogLevel::WARNING, 'Attempting to verify an association with a shared key.');
732: return FALSE;
733: }
734:
735: if ($association->getHandle() != $request['openid.assoc_handle']) {
736: $this->logger->log(LogLevel::WARNING, 'Attempting to verify a response_nonce more than once, or private association expired.');
737: return FALSE;
738: } else {
739: $signature = $association->sign($request);
740: $this->logger->log(LogLevel::DEBUG, '***** Signature: ' . $signature);
741:
742: if ($signature != $request['openid.sig']) {
743: $this->logger->log(LogLevel::WARNING, 'Signature supplied in request does not match the signature generated.');
744: return FALSE;
745: }
746: }
747:
748: return true;
749: }
750:
751: /**
752: * Provides a form for user consent of an OpenID relying party, where the
753: * {@link simpleid_checkid_identity()} function returns a CHECKID_APPROVAL_REQUIRED
754: * or CHECKID_RETURN_TO_SUSPECT.
755: *
756: * Alternatively, provide a form for the user to rectify the situation where
757: * {@link simpleid_checkid_identity()} function returns a CHECKID_IDENTITIES_NOT_MATCHING
758: * or CHECKID_IDENTITY_NOT_EXIST
759: *
760: * @param Request $request the original OpenID request
761: * @param Response $response the proposed OpenID response, subject to user
762: * verification
763: * @param int $reason either CHECKID_APPROVAL_REQUIRED, CHECKID_RETURN_TO_SUSPECT,
764: * CHECKID_IDENTITIES_NOT_MATCHING or CHECKID_IDENTITY_NOT_EXIST
765: * @return void
766: */
767: protected function consentForm($request, $response, $reason = self::CHECKID_APPROVAL_REQUIRED) {
768: $tpl = Template::instance();
769: $token = new SecurityToken();
770: $auth = AuthManager::instance();
771: $user = $auth->getUser();
772:
773: $form_state = new FormState([
774: 'code' => $reason
775: ]);
776: $form_state->setRequest($request);
777: $form_state->setResponse($response);
778: $cancel = ($response['mode'] == 'cancel');
779:
780: $realm = $request->getRealm();
781: $request_state = new RequestState();
782: $request_state->setParams($request->toArray());
783:
784: $this->f3->set('realm', $realm);
785:
786: if ($cancel) {
787: $this->f3->set('requested_identity', $request['openid.identity']);
788: $this->f3->set('switch_url', $this->getCanonicalURL('auth/logout/continue/' . rawurlencode($token->generate($request_state)), '', 'detect'));
789: } else {
790: $base_path = $this->f3->get('base_path');
791:
792: $form_state['prefs'] = (isset($user->clients[$realm])) ? $user->clients[$realm] : [];
793:
794: $event = new FormBuildEvent($form_state, 'openid_consent_form_build');
795: \Events::instance()->dispatch($event);
796: $tpl->mergeAttachments($event);
797: $this->f3->set('forms', $event->getBlocks());
798:
799: if ($reason == self::CHECKID_RETURN_TO_SUSPECT) {
800: $this->f3->set('return_to_suspect', true);
801: $this->f3->set('suspect_url', 'http://simpleid.org/documentation/troubleshooting/returnto-discovery-failure');
802: $this->f3->set('js_data.intl.openid_suspect', $this->f3->get('intl.core.openid.suspect_js_1') . "\n\n" . $this->f3->get('intl.core.openid.suspect_js_2'));
803: }
804: }
805:
806: $this->f3->set('tk', $token->generate('openid_consent', SecurityToken::OPTION_BIND_SESSION));
807: $this->f3->set('fs', $token->generate($form_state->encode()));
808:
809: $this->f3->set('cancel', $cancel);
810:
811: $this->f3->set('logout_destination', '/continue/' . rawurlencode($token->generate($request_state)));
812: $this->f3->set('user_header', true);
813: $this->f3->set('title', $this->f3->get('intl.core.openid.openid_title'));
814: $this->f3->set('page_class', 'is-dialog-page');
815: $this->f3->set('layout', 'openid_consent.html');
816:
817: header('X-Frame-Options: DENY');
818: print $tpl->render('page.html');
819: }
820:
821: /**
822: * Processes a user response from the {@link simpleid_openid_consent_form()} function.
823: *
824: * If the user verifies the relying party, an OpenID response will be sent to
825: * the relying party. Otherwise, the dashboard will be displayed to the user.
826: *
827: * @return void
828: */
829: public function consent() {
830: $auth = AuthManager::instance();
831: $token = new SecurityToken();
832: $store = StoreManager::instance();
833:
834: if (!$auth->isLoggedIn()) {
835: /** @var \SimpleID\Auth\AuthModule */
836: $auth_module = $this->mgr->getModule('SimpleID\Auth\AuthModule');
837: $auth_module->loginForm();
838: return;
839: }
840: $user = $auth->getUser();
841:
842: $form_state = FormState::decode($token->getPayload($this->f3->get('POST.fs')), Request::class, Response::class);
843: /** @var Request */
844: $request = $form_state->getRequest();
845: /** @var Response */
846: $response = $form_state->getResponse();
847: $reason = $form_state['code'];
848:
849: if (!$token->verify($this->f3->get('POST.tk'), 'openid_consent')) {
850: $this->logger->log(LogLevel::WARNING, 'Security token ' . $this->f3->get('POST.tk') . ' invalid.');
851: $this->f3->set('message', $this->f3->get('intl.common.invalid_tk'));
852: $this->consentForm($request, $response, $reason);
853: return;
854: }
855:
856: $return_to = $response['return_to'];
857: if ($return_to == null) $return_to = $request['openid.return_to'];
858:
859: if ($this->f3->get('POST.op') == 'cancel') {
860: $response = $this->createErrorResponse($request, false);
861: if (!$return_to) $this->f3->set('message', $this->f3->get('intl.common.login_cancelled'));
862: } else {
863: $event = new FormSubmitEvent($form_state, 'openid_consent_form_submit');
864: \Events::instance()->dispatch($event);
865:
866: $consents = [ 'openid' => ($this->f3->exists('POST.prefs.consents.openid') && ($this->f3->get('POST.prefs.consents.openid') == 'true')) ];
867: $this->logActivity($request, $consents);
868:
869: $this->signResponse($response, isset($response['assoc_handle']) ? $response['assoc_handle'] : NULL);
870: if (!$return_to) $this->f3->set('message', $this->f3->get('intl.common.login_success'));
871: }
872:
873: if ($return_to) {
874: $response->render($return_to);
875: } else {
876: $this->f3->reroute('/');
877: }
878: }
879:
880: /**
881: * Processes a cancellation from the login form.
882: *
883: * @param FormSubmitEvent $event the form cancellation
884: * event
885: * @return void
886: */
887: public function onLoginFormCancel(FormSubmitEvent $event) {
888: $form_state = $event->getFormState();
889:
890: if ($form_state['cancel'] == 'openid') {
891: /** @var \SimpleID\Protocols\OpenID\Request */
892: $request = $form_state->getRequest();
893: if (isset($request['openid.return_to'])) {
894: $return_to = $request['openid.return_to'];
895: $response = $this->createErrorResponse($request, FALSE);
896: $response->render($return_to);
897: $event->stopPropagation();
898: return;
899: }
900: }
901: }
902:
903: /**
904: * Logs the authentication activity against the user.
905: *
906: * @param Request $request the OpenID request
907: * @param array<string, mixed>|null $consents if not `null`, saves the consents
908: * @return void
909: */
910: protected function logActivity($request, $consents = NULL) {
911: $store = StoreManager::instance();
912:
913: $auth = AuthManager::instance();
914: $user = $auth->getUser();
915:
916: $realm = $request->getRealm();
917: $cid = RelyingParty::buildID($realm);
918: $relying_party = $this->loadRelyingParty($realm, true);
919:
920: $now = time();
921:
922: $event = new ProtocolResultEvent(self::CHECKID_OK, $user, $this->loadRelyingParty($realm, true));
923: \Events::instance()->dispatch($event);
924:
925: if (isset($user->clients[$cid])) {
926: $prefs = $user->clients[$cid];
927: } else {
928: $prefs = [
929: 'openid' => [
930: 'version' => $request->getVersion()
931: ],
932: 'store_id' => $relying_party->getStoreID(),
933: 'display_name' => $relying_party->getDisplayName(),
934: 'display_html' => $relying_party->getDisplayHTML(),
935: 'first_time' => $now,
936: 'consents' => []
937: ];
938: }
939:
940: $prefs['last_time'] = $now;
941:
942: if ($consents != null) {
943: $prefs['consents'] = $consents;
944: }
945:
946: $user->clients[$cid] = $prefs;
947: $store->saveUser($user);
948: }
949:
950:
951: /**
952: * Sends a direct message indicating an error. This is a convenience function
953: * for {@link renderDirectResponse()}.
954: *
955: * @param string $error the error message
956: * @param array<string, string> $additional any additional data to be sent with the error
957: * message
958: * @param Request $request the request in response to which the error is made
959: * @return void
960: */
961: protected function directError($error, $additional = [], $request = NULL) {
962: $this->f3->status(400);
963:
964: $error = Response::createError($error, $additional, $request);
965: $error->render();
966: }
967:
968: /**
969: * Sends an indirect message indicating an error. This is a convenience function
970: * for {@link openid_indirect_response()}.
971: *
972: * @param string $url the URL to which the error message is to be sent
973: * @param string $error the error message or code
974: * @param array<string, string> $additional any additional data to be sent with the error
975: * message
976: * @param Request $request the request in response to which the error is made
977: * @return void
978: */
979: protected function indirectError($url, $error, $additional = [], $request = NULL) {
980: $error = Response::createError($error, $additional, $request);
981: $error->render($url);
982: }
983:
984: /**
985: * Obtains information on a relying party by performing discovery on them. Information
986: * obtained includes the discovery URL, the parsed XRDS document, and any other
987: * information saved by SimpleID extensions
988: *
989: * @param string $realm the openid.realm parameter
990: * @param bool $allow_stale allow stale results to be returned, otherwise discovery
991: * will occur
992: * @return RelyingParty containing information on a relying party.
993: * @link http://openid.net/specs/openid-authentication-2_0.html#rp_discovery
994: * @since 0.8
995: */
996: public function loadRelyingParty($realm, $allow_stale = false) {
997: $store = StoreManager::instance();
998:
999: $cid = RelyingParty::buildID($realm);
1000:
1001: /** @var RelyingParty $relying_party */
1002: $relying_party = $store->loadClient($cid);
1003: if ($relying_party == null) $relying_party = new RelyingParty($realm);
1004:
1005: if ((time() - $relying_party->getDiscoveryTime() > SIMPLEID_SHORT_TOKEN_EXPIRES_IN) && !$allow_stale) {
1006: $this->logger->log(LogLevel::INFO, 'OpenID 2 RP discovery: realm: ' . $realm);
1007: $relying_party->discover();
1008: $store->saveClient($relying_party);
1009: }
1010:
1011: return $relying_party;
1012: }
1013:
1014: /**
1015: * Displays the XRDS document for this SimpleID installation.
1016: *
1017: * @return void
1018: */
1019: public function providerXRDS() {
1020: $this->logger->log(LogLevel::DEBUG, 'Providing XRDS.');
1021:
1022: $tpl = Template::instance();
1023:
1024: $event = new BaseDataCollectionEvent('xrds_types');
1025: \Events::instance()->dispatch($event);
1026:
1027: $this->f3->set('types', $event->getResults());
1028:
1029: header('Content-Disposition: inline; filename=yadis.xml');
1030: print $tpl->render('openid_provider_xrds.xml', 'application/xrds+xml');
1031: }
1032:
1033:
1034: /**
1035: * Returns the user's public XRDS page.
1036: *
1037: * @return void
1038: */
1039: public function userXRDS() {
1040: $store = StoreManager::instance();
1041: $user = $store->loadUser($this->f3->get('PARAMS.uid'));
1042:
1043: if ($user != NULL) {
1044: $tpl = Template::instance();
1045:
1046: if ($user->hasLocalOpenIDIdentity()) {
1047: $this->f3->set('local_id', $user['openid']["identity"]);
1048: }
1049: header('Content-Disposition: inline; filename=yadis.xml');
1050: print $tpl->render('openid_user_xrds.xml', 'application/xrds+xml');
1051: } else {
1052: $this->fatalError($this->f3->get('intl.common.user_not_found', $this->f3->get('PARAMS.uid')), 404);
1053: }
1054: }
1055:
1056: /**
1057: * Returns the OpenID Connect scopes supported by this server.
1058: *
1059: * @return void
1060: * @since 2.0
1061: */
1062: public function onScopeInfoCollectionEvent(ScopeInfoCollectionEvent $event) {
1063: $event->addScopeInfo('openid', [
1064: self::DEFAULT_SCOPE => [
1065: 'description' => $this->f3->get('intl.common.scope.id'),
1066: ]
1067: ]);
1068: }
1069:
1070: /**
1071: * Returns a block containing discovery information.
1072: *
1073: * @param UIBuildEvent $event the event to collect
1074: * the discovery block
1075: * @return void
1076: */
1077: public function onProfileBlocks(UIBuildEvent $event) {
1078: $auth = AuthManager::instance();
1079: $user = $auth->getUser();
1080: $tpl = Template::instance();
1081:
1082: $this->f3->set('js_data.intl', [ 'code' => addslashes($this->f3->get('intl.core.openid.profile_js')) ]);
1083:
1084: $xrds_url = $this->getCanonicalURL('user/'. $user['uid'] . '/xrds', '', 'detect');
1085: $hive = [
1086: 'config' => $this->f3->get('config'),
1087: 'intl' => $this->f3->get('intl'),
1088: 'user' => $user,
1089: 'xrds_url' => $xrds_url
1090: ];
1091:
1092: $event->addBlock('discovery', $tpl->render('openid_profile.html', false, $hive), 1, [
1093: 'title' => $this->f3->get('intl.core.openid.discovery_title'),
1094: 'links' => [ [ 'href' => 'http://simpleid.org/documentation/getting-started/setting-identity/claim-your-identifier', 'name' => $this->f3->get('intl.common.more_info') ] ],
1095: ]);
1096: }
1097: }
1098:
1099: ?>
1100: