*/ use Friendica\App; use Friendica\Content\Text\BBCode; use Friendica\Core\Hook; use Friendica\Core\Logger; use Friendica\Core\Renderer; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\User; use Friendica\Util\Strings; use OneLogin\Saml2\Utils; require_once(__DIR__ . '/vendor/autoload.php'); define('PW_LEN', 32); // number of characters to use for random passwords function saml_module($a) { } function saml_init(App $a) { if (DI::args()->getArgc() < 2) { return; } if (!saml_is_configured()) { echo 'Please configure the SAML add-on via the admin interface.'; return; } switch (DI::args()->get(1)) { case 'metadata.xml': saml_metadata(); break; case 'sso': saml_sso_reply($a); break; case 'slo': saml_slo_reply(); break; } exit(); } function saml_metadata() { try { $settings = new \OneLogin\Saml2\Settings(saml_settings()); $metadata = $settings->getSPMetadata(); $errors = $settings->validateMetadata($metadata); if (empty($errors)) { header('Content-Type: text/xml'); echo $metadata; } else { throw new \OneLogin\Saml2\Error( 'Invalid SP metadata: '.implode(', ', $errors), \OneLogin\Saml2\Error::METADATA_SP_INVALID ); } } catch (Exception $e) { Logger::error($e->getMessage()); } } function saml_install() { Hook::register('login_hook', __FILE__, 'saml_sso_initiate'); Hook::register('logging_out', __FILE__, 'saml_slo_initiate'); Hook::register('head', __FILE__, 'saml_head'); Hook::register('footer', __FILE__, 'saml_footer'); } function saml_head(App $a, string &$body) { DI::page()->registerStylesheet(__DIR__ . '/saml.css'); } function saml_footer(App $a, string &$body) { $fragment = addslashes(BBCode::convert(DI::config()->get('saml', 'settings_statement'))); $body .= << var target=$("#settings-nickname-desc"); if (target.length) { target.append("

$fragment

"); } EOL; } function saml_is_configured() { return DI::config()->get('saml', 'idp_id') && DI::config()->get('saml', 'client_id') && DI::config()->get('saml', 'sso_url') && DI::config()->get('saml', 'slo_request_url') && DI::config()->get('saml', 'slo_response_url') && DI::config()->get('saml', 'sp_key') && DI::config()->get('saml', 'sp_cert') && DI::config()->get('saml', 'idp_cert'); } function saml_sso_initiate(App $a, string &$body) { if (!saml_is_configured()) { Logger::warning('SAML SSO tried to trigger, but the SAML addon is not configured yet!'); return; } $auth = new \OneLogin\Saml2\Auth(saml_settings()); $ssoBuiltUrl = $auth->login(null, [], false, false, true); DI::session()->set('AuthNRequestID', $auth->getLastRequestID()); header('Pragma: no-cache'); header('Cache-Control: no-cache, must-revalidate'); header('Location: ' . $ssoBuiltUrl); exit(); } function saml_sso_reply(App $a) { $auth = new \OneLogin\Saml2\Auth(saml_settings()); $requestID = null; if (DI::session()->exists('AuthNRequestID')) { $requestID = DI::session()->get('AuthNRequestID'); } $auth->processResponse($requestID); DI::session()->remove('AuthNRequestID'); $errors = $auth->getErrors(); if (!empty($errors)) { echo 'Errors encountered.'; Logger::error(implode(', ', $errors)); exit(); } if (!$auth->isAuthenticated()) { echo 'Not authenticated'; exit(); } $username = $auth->getNameId(); $email = $auth->getAttributeWithFriendlyName('email')[0]; $name = $auth->getAttributeWithFriendlyName('givenName')[0]; $last_name = $auth->getAttributeWithFriendlyName('surname')[0]; if (strlen($last_name)) { $name .= " $last_name"; } if (!DBA::exists('user', ['nickname' => $username])) { $user = saml_create_user($username, $email, $name); } else { $user = User::getByNickname($username); } if (!empty($user['uid'])) { DI::auth()->setForUser($a, $user); } if (isset($_POST['RelayState']) && Utils::getSelfURL() != $_POST['RelayState']) { $auth->redirectTo($_POST['RelayState']); } } function saml_slo_initiate(App $a) { if (!saml_is_configured()) { Logger::warning('SAML SLO tried to trigger, but the SAML addon is not configured yet!'); return; } $auth = new \OneLogin\Saml2\Auth(saml_settings()); $sloBuiltUrl = $auth->logout(); DI::session()->set('LogoutRequestID', $auth->getLastRequestID()); header('Pragma: no-cache'); header('Cache-Control: no-cache, must-revalidate'); header('Location: ' . $sloBuiltUrl); exit(); } function saml_slo_reply() { $auth = new \OneLogin\Saml2\Auth(saml_settings()); if (DI::session()->exists('LogoutRequestID')) { $requestID = DI::session()->get('LogoutRequestID'); } else { $requestID = null; } $auth->processSLO(false, $requestID); $errors = $auth->getErrors(); if (empty($errors)) { $auth->redirectTo(DI::baseUrl()); } else { Logger::error(implode(', ', $errors)); } } function saml_input($key, $label, $description) { return [ '$' . $key => [ $key, $label, DI::config()->get('saml', $key), $description, true, // all the fields are required ] ]; } function saml_addon_admin(App $a, string &$o) { $form = saml_input( 'settings_statement', DI::l10n()->t('Settings statement'), DI::l10n()->t('A statement on the settings page explaining where the user should go to change ' . 'their e-mail and password. BBCode allowed.') ) + saml_input( 'idp_id', DI::l10n()->t('IdP ID'), DI::l10n()->t('Identity provider (IdP) entity URI (e.g., https://example.com/auth/realms/user).') ) + saml_input( 'client_id', DI::l10n()->t('Client ID'), DI::l10n()->t('Identifier assigned to client by the identity provider (IdP).') ) + saml_input( 'sso_url', DI::l10n()->t('IdP SSO URL'), DI::l10n()->t('The URL for your identity provider\'s SSO endpoint.') ) + saml_input( 'slo_request_url', DI::l10n()->t('IdP SLO request URL'), DI::l10n()->t('The URL for your identity provider\'s SLO request endpoint.') ) + saml_input( 'slo_response_url', DI::l10n()->t('IdP SLO response URL'), DI::l10n()->t('The URL for your identity provider\'s SLO response endpoint.') ) + saml_input( 'sp_key', DI::l10n()->t('SP private key'), DI::l10n()->t('The private key the addon should use to authenticate.') ) + saml_input( 'sp_cert', DI::l10n()->t('SP certificate'), DI::l10n()->t('The certficate for the addon\'s private key.') ) + saml_input( 'idp_cert', DI::l10n()->t('IdP certificate'), DI::l10n()->t('The x509 certficate for your identity provider.') ) + [ '$submit' => DI::l10n()->t('Save Settings'), ]; $t = Renderer::getMarkupTemplate('admin.tpl', 'addon/saml/'); $o = Renderer::replaceMacros($t, $form); } function saml_addon_admin_post(App $a) { $set = function ($key) { $val = (!empty($_POST[$key]) ? trim($_POST[$key]) : ''); DI::config()->set('saml', $key, $val); }; $set('idp_id'); $set('client_id'); $set('sso_url'); $set('slo_request_url'); $set('slo_response_url'); $set('sp_key'); $set('sp_cert'); $set('idp_cert'); $set('settings_statement'); } function saml_create_user($username, $email, $name) { if (!strlen($email) || !strlen($name)) { Logger::error('Could not create user: no email or username given.'); return false; } try { $strong = false; $bytes = openssl_random_pseudo_bytes(intval(ceil(PW_LEN * 0.75)), $strong); if (!$strong) { throw new Exception('Strong algorithm not available for PRNG.'); } $user = User::create([ 'username' => $name, 'nickname' => $username, 'email' => $email, 'password' => base64_encode($bytes), // should be at least PW_LEN long 'verified' => true ]); return $user; } catch (Exception $e) { Logger::error( 'Exception while creating user', [ 'username' => $username, 'email' => $email, 'name' => $name, 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString() ] ); return false; } } function saml_settings() { return [ // If 'strict' is True, then the PHP Toolkit will reject unsigned // or unencrypted messages if it expects them to be signed or encrypted. // Also it will reject the messages if the SAML standard is not strictly // followed: Destination, NameId, Conditions ... are validated too. // Should never be set to anything else in production! 'strict' => true, // Enable debug mode (to print errors). 'debug' => false, // Set a BaseURL to be used instead of try to guess // the BaseURL of the view that process the SAML Message. // Ex http://sp.example.com/ // http://example.com/sp/ 'baseurl' => DI::baseUrl() . '/saml', // Service Provider Data that we are deploying. 'sp' => [ // Identifier of the SP entity (must be a URI) 'entityId' => DI::config()->get('saml', 'client_id'), // Specifies info about where and how the message MUST be // returned to the requester, in this case our SP. 'assertionConsumerService' => [ // URL Location where the from the IdP will be returned 'url' => DI::baseUrl() . '/saml/sso', // SAML protocol binding to be used when returning the // message. OneLogin Toolkit supports this endpoint for the // HTTP-POST binding only. 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', ], // If you need to specify requested attributes, set a // attributeConsumingService. nameFormat, attributeValue and // friendlyName can be omitted 'attributeConsumingService'=> [ 'serviceName' => 'Friendica SAML SSO and SLO Addon', 'serviceDescription' => 'SLO and SSO support for Friendica', 'requestedAttributes' => [ [ 'uid' => '', 'isRequired' => false, ] ] ], // Specifies info about where and how the message MUST be // returned to the requester, in this case our SP. 'singleLogoutService' => [ // URL Location where the from the IdP will be returned 'url' => DI::baseUrl() . '/saml/slo', // SAML protocol binding to be used when returning the // message. OneLogin Toolkit supports the HTTP-Redirect binding // only for this endpoint. 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', ], // Specifies the constraints on the name identifier to be used to // represent the requested subject. // Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported. 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified', // Usually x509cert and privateKey of the SP are provided by files placed at // the certs folder. But we can also provide them with the following parameters 'x509cert' => DI::config()->get('saml', 'sp_cert'), 'privateKey' => DI::config()->get('saml', 'sp_key'), ], // Identity Provider Data that we want connected with our SP. 'idp' => [ // Identifier of the IdP entity (must be a URI) 'entityId' => DI::config()->get('saml', 'idp_id'), // SSO endpoint info of the IdP. (Authentication Request protocol) 'singleSignOnService' => [ // URL Target of the IdP where the Authentication Request Message // will be sent. 'url' => DI::config()->get('saml', 'sso_url'), // SAML protocol binding to be used when returning the // message. OneLogin Toolkit supports the HTTP-Redirect binding // only for this endpoint. 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', ], // SLO endpoint info of the IdP. 'singleLogoutService' => [ // URL Location of the IdP where SLO Request will be sent. 'url' => DI::config()->get('saml', 'slo_request_url'), // URL location of the IdP where SLO Response will be sent (ResponseLocation) // if not set, url for the SLO Request will be used 'responseUrl' => DI::config()->get('saml', 'slo_response_url'), // SAML protocol binding to be used when returning the // message. OneLogin Toolkit supports the HTTP-Redirect binding // only for this endpoint. 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', ], // Public x509 certificate of the IdP 'x509cert' => DI::config()->get('saml', 'idp_cert'), ], 'security' => [ 'wantXMLValidation' => false, // Indicates whether the messages sent by this SP // will be signed. [Metadata of the SP will offer this info] 'authnRequestsSigned' => true, // Indicates whether the messages sent by this SP // will be signed. 'logoutRequestSigned' => true, // Indicates whether the messages sent by this SP // will be signed. 'logoutResponseSigned' => true, // Sign the Metadata 'signMetadata' => true, ] ]; }