3 * Name: SAML SSO and SLO
4 * Description: replace login and registration with a SAML identity provider.
6 * Author: Ryan <https://friendica.verya.pe/profile/ryan>
8 use Friendica\Core\Hook;
9 use Friendica\Core\Logger;
10 use Friendica\Core\Renderer;
11 use Friendica\Core\Session;
12 use Friendica\Database\DBA;
14 use Friendica\Model\User;
15 use Friendica\Util\Strings;
16 use OneLogin\Saml2\Auth;
18 require_once(__DIR__ . '/vendor/autoload.php');
20 define("PW_LEN", 32); // number of characters to use for random passwords
22 function saml_module($a) {}
24 function saml_init($a) {
25 if ($a->argc < 2) return;
27 switch ($a->argv[1]) {
39 echo $_SERVER['REQUEST_URI'];
45 function saml_metadata() {
47 $settings = new \OneLogin\Saml2\Settings(saml_settings());
48 $metadata = $settings->getSPMetadata();
49 $errors = $settings->validateMetadata($metadata);
52 header('Content-Type: text/xml');
55 throw new \OneLogin\Saml2\Error(
56 'Invalid SP metadata: '.implode(', ', $errors),
57 \OneLogin\Saml2\Error::METADATA_SP_INVALID
60 } catch (Exception $e) {
61 Logger::error($e->getMessage());
65 function saml_install() {
66 Hook::register('login_hook', 'addon/saml/saml.php', 'saml_sso_initiate');
67 Hook::register('logging_out', 'addon/saml/saml.php', 'saml_slo_initiate');
68 Hook::register('head', 'addon/saml/saml.php', 'saml_head');
69 Hook::register('footer', 'addon/saml/saml.php', 'saml_footer');
72 function saml_head(&$a, &$b) {
73 DI::page()->registerStylesheet(__DIR__ . '/saml.css');
76 function saml_footer(&$a, &$b) {
77 $fragment = addslashes(DI::config()->get('saml', 'settings_statement'));
80 var target=$("#settings-nickname-desc");
81 if (target.length) { target.append("$fragment"); }
86 function saml_is_configured() {
88 DI::config()->get('saml', 'idp_id') &&
89 DI::config()->get('saml', 'client_id') &&
90 DI::config()->get('saml', 'sso_url') &&
91 DI::config()->get('saml', 'slo_request_url') &&
92 DI::config()->get('saml', 'slo_response_url') &&
93 DI::config()->get('saml', 'sp_key') &&
94 DI::config()->get('saml', 'sp_cert') &&
95 DI::config()->get('saml', 'idp_cert');
99 function saml_sso_initiate(&$a, &$b) {
100 if (!saml_is_configured()) return;
102 $auth = new \OneLogin\Saml2\Auth(saml_settings());
103 $ssoBuiltUrl = $auth->login(null, array(), false, false, true);
104 $_SESSION['AuthNRequestID'] = $auth->getLastRequestID();
105 header('Pragma: no-cache');
106 header('Cache-Control: no-cache, must-revalidate');
107 header('Location: ' . $ssoBuiltUrl);
111 function saml_sso_reply($a) {
112 $auth = new \OneLogin\Saml2\Auth(saml_settings());
115 if (isset($_SESSION) && isset($_SESSION['AuthNRequestID'])) {
116 $requestID = $_SESSION['AuthNRequestID'];
119 $auth->processResponse($requestID);
120 unset($_SESSION['AuthNRequestID']);
122 $errors = $auth->getErrors();
124 if (!empty($errors)) {
125 echo "Errors encountered.";
126 Logger::error(implode(', ', $errors));
130 if (!$auth->isAuthenticated()) {
131 echo "Not authenticated";
135 $username = $auth->getNameId();
136 $email = $auth->getAttributeWithFriendlyName('email')[0];
137 $name = $auth->getAttributeWithFriendlyName('givenName')[0];
138 $last_name = $auth->getAttributeWithFriendlyName('surname')[0];
140 if (strlen($last_name)) {
141 $name .= " $last_name";
144 if (!DBA::exists('user', ['nickname' => $username])) {
145 $user = saml_create_user($username, $email, $name);
147 $user = User::getByNickname($username);
150 if (!empty($user['uid'])) {
151 DI::auth()->setForUser($a, $user);
154 if (isset($_POST['RelayState'])
155 && \OneLogin\Saml2\Utils::getSelfURL() != $_POST['RelayState'])
157 $auth->redirectTo($_POST['RelayState']);
161 function saml_slo_initiate(&$a, &$b) {
162 $auth = new \OneLogin\Saml2\Auth(saml_settings());
164 $sloBuiltUrl = $auth->logout();
165 $_SESSION['LogoutRequestID'] = $auth->getLastRequestID();
166 header('Pragma: no-cache');
167 header('Cache-Control: no-cache, must-revalidate');
168 header('Location: ' . $sloBuiltUrl);
172 function saml_slo_reply() {
173 $auth = new \OneLogin\Saml2\Auth(saml_settings());
175 if (isset($_SESSION) && isset($_SESSION['LogoutRequestID'])) {
176 $requestID = $_SESSION['LogoutRequestID'];
181 $auth->processSLO(false, $requestID);
183 $errors = $auth->getErrors();
185 if (empty($errors)) {
186 $auth->redirectTo(DI::baseUrl());
188 Logger::error(implode(', ', $errors));
192 function saml_input($key, $label, $description) {
196 DI::l10n()->t($label),
197 DI::config()->get('saml', $key),
198 DI::l10n()->t($description),
199 true, // all the fields are required
204 function saml_addon_admin (&$a, &$o) {
207 'settings_statement',
208 'Settings statement',
209 'A statement on the settings page explaining where the user should go to change their e-mail and password. HTML allowed.'
214 'Identity provider (IdP) entity URI (e.g., https://example.com/auth/realms/user).'
219 'Identifier assigned to client by the identity provider (IdP).'
224 'The URL for your identity provider\'s SSO endpoint.'
228 'IdP SLO request URL',
229 'The URL for your identity provider\'s SLO request endpoint.'
233 'IdP SLO response URL',
234 'The URL for your identity provider\'s SLO response endpoint.'
239 'The private key the addon should use to authenticate.'
244 'The certficate for the addon\'s private key.'
249 'The x509 certficate for your identity provider.'
252 '$submit' => DI::l10n()->t('Save Settings'),
254 $t = Renderer::getMarkupTemplate( "admin.tpl", "addon/saml/" );
255 $o = Renderer::replaceMacros( $t, $form);
258 function saml_addon_admin_post (&$a) {
259 $safeset = function ($key) {
260 $val = (!empty($_POST[$key]) ? Strings::escapeTags(trim($_POST[$key])) : '');
261 DI::config()->set('saml', $key, $val);
264 $safeset('client_id');
266 $safeset('slo_request_url');
267 $safeset('slo_response_url');
270 $safeset('idp_cert');
272 // Not using safeset here since settings_statement is *meant* to include HTML tags.
273 DI::config()->set('saml', 'settings_statement', $_POST['settings_statement']);
276 function saml_create_user($username, $email, $name) {
277 if (!strlen($email) || !strlen($name)) {
278 Logger::error('Could not create user: no email or username given.');
284 $bytes = openssl_random_pseudo_bytes(intval(ceil(PW_LEN * 0.75)), $strong);
287 throw new Exception('Strong algorithm not available for PRNG.');
290 $user = User::create([
292 'nickname' => $username,
294 'password' => base64_encode($bytes), // should be at least PW_LEN long
300 } catch (Exception $e) {
302 'Exception while creating user',
304 'username' => $username,
307 'exception' => $e->getMessage(),
308 'trace' => $e->getTraceAsString()
315 function saml_settings() {
317 // If 'strict' is True, then the PHP Toolkit will reject unsigned
318 // or unencrypted messages if it expects them to be signed or encrypted.
319 // Also it will reject the messages if the SAML standard is not strictly
320 // followed: Destination, NameId, Conditions ... are validated too.
321 // Should never be set to anything else in production!
324 // Enable debug mode (to print errors).
327 // Set a BaseURL to be used instead of try to guess
328 // the BaseURL of the view that process the SAML Message.
329 // Ex http://sp.example.com/
330 // http://example.com/sp/
331 'baseurl' => DI::baseUrl() . "/saml",
333 // Service Provider Data that we are deploying.
335 // Identifier of the SP entity (must be a URI)
336 'entityId' => DI::config()->get('saml','client_id'),
337 // Specifies info about where and how the <AuthnResponse> message MUST be
338 // returned to the requester, in this case our SP.
339 'assertionConsumerService' => array(
340 // URL Location where the <Response> from the IdP will be returned
341 'url' => DI::baseUrl() . "/saml/sso",
342 // SAML protocol binding to be used when returning the <Response>
343 // message. OneLogin Toolkit supports this endpoint for the
344 // HTTP-POST binding only.
345 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
347 // If you need to specify requested attributes, set a
348 // attributeConsumingService. nameFormat, attributeValue and
349 // friendlyName can be omitted
350 "attributeConsumingService"=> array(
351 "serviceName" => "Friendica SAML SSO and SLO Addon",
352 "serviceDescription" => "SLO and SSO support for Friendica",
353 "requestedAttributes" => array(
356 "isRequired" => false,
360 // Specifies info about where and how the <Logout Response> message MUST be
361 // returned to the requester, in this case our SP.
362 'singleLogoutService' => array(
363 // URL Location where the <Response> from the IdP will be returned
364 'url' => DI::baseUrl() . "/saml/slo",
365 // SAML protocol binding to be used when returning the <Response>
366 // message. OneLogin Toolkit supports the HTTP-Redirect binding
367 // only for this endpoint.
368 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
370 // Specifies the constraints on the name identifier to be used to
371 // represent the requested subject.
372 // Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported.
373 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',
374 // Usually x509cert and privateKey of the SP are provided by files placed at
375 // the certs folder. But we can also provide them with the following parameters
376 'x509cert' => DI::config()->get('saml','sp_cert'),
377 'privateKey' => DI::config()->get('saml','sp_key'),
380 // Identity Provider Data that we want connected with our SP.
382 // Identifier of the IdP entity (must be a URI)
383 'entityId' => DI::config()->get('saml','idp_id'),
384 // SSO endpoint info of the IdP. (Authentication Request protocol)
385 'singleSignOnService' => array(
386 // URL Target of the IdP where the Authentication Request Message
388 'url' => DI::config()->get('saml','sso_url'),
389 // SAML protocol binding to be used when returning the <Response>
390 // message. OneLogin Toolkit supports the HTTP-Redirect binding
391 // only for this endpoint.
392 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
394 // SLO endpoint info of the IdP.
395 'singleLogoutService' => array(
396 // URL Location of the IdP where SLO Request will be sent.
397 'url' => DI::config()->get('saml','slo_request_url'),
398 // URL location of the IdP where SLO Response will be sent (ResponseLocation)
399 // if not set, url for the SLO Request will be used
400 'responseUrl' => DI::config()->get('saml','slo_response_url'),
401 // SAML protocol binding to be used when returning the <Response>
402 // message. OneLogin Toolkit supports the HTTP-Redirect binding
403 // only for this endpoint.
404 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
406 // Public x509 certificate of the IdP
407 'x509cert' => DI::config()->get('saml','idp_cert'),
409 'security' => array (
410 'wantXMLValidation' => false,
412 // Indicates whether the <samlp:AuthnRequest> messages sent by this SP
413 // will be signed. [Metadata of the SP will offer this info]
414 'authnRequestsSigned' => true,
416 // Indicates whether the <samlp:logoutRequest> messages sent by this SP
418 'logoutRequestSigned' => true,
420 // Indicates whether the <samlp:logoutResponse> messages sent by this SP
422 'logoutResponseSigned' => true,
424 /* Sign the Metadata */
425 'signMetadata' => true,