]> git.mxchange.org Git - friendica-addons.git/blob - saml/saml.php
53e65b570f1e81480b452a0cd99a7e25d20955c6
[friendica-addons.git] / saml / saml.php
1 <?php
2 /*
3  * Name: SAML SSO and SLO
4  * Description: replace login and registration with a SAML identity provider.
5  * Version: 1.0
6  * Author: Ryan <https://friendica.verya.pe/profile/ryan>
7  */
8
9 use Friendica\App;
10 use Friendica\Content\Text\BBCode;
11 use Friendica\Core\Hook;
12 use Friendica\Core\Logger;
13 use Friendica\Core\Renderer;
14 use Friendica\Database\DBA;
15 use Friendica\DI;
16 use Friendica\Model\User;
17 use Friendica\Util\Strings;
18 use OneLogin\Saml2\Utils;
19
20 require_once(__DIR__ . '/vendor/autoload.php');
21
22 define('PW_LEN', 32); // number of characters to use for random passwords
23
24 function saml_module($a)
25 {
26 }
27
28 function saml_init(App $a)
29 {
30         if (DI::args()->getArgc() < 2) {
31                 return;
32         }
33
34         if (!saml_is_configured()) {
35                 echo 'Please configure the SAML add-on via the admin interface.';
36                 return;
37         }
38
39         switch (DI::args()->get(1)) {
40                 case 'metadata.xml':
41                         saml_metadata();
42                         break;
43                 case 'sso':
44                         saml_sso_reply($a);
45                         break;
46                 case 'slo':
47                         saml_slo_reply();
48                         break;
49         }
50         exit();
51 }
52
53 function saml_metadata()
54 {
55         try {
56                 $settings = new \OneLogin\Saml2\Settings(saml_settings());
57                 $metadata = $settings->getSPMetadata();
58                 $errors = $settings->validateMetadata($metadata);
59
60                 if (empty($errors)) {
61                         header('Content-Type: text/xml');
62                         echo $metadata;
63                 } else {
64                         throw new \OneLogin\Saml2\Error(
65                                 'Invalid SP metadata: '.implode(', ', $errors),
66                                 \OneLogin\Saml2\Error::METADATA_SP_INVALID
67                         );
68                 }
69         } catch (Exception $e) {
70                 Logger::error($e->getMessage());
71         }
72 }
73
74 function saml_install()
75 {
76         Hook::register('login_hook', __FILE__, 'saml_sso_initiate');
77         Hook::register('logging_out', __FILE__, 'saml_slo_initiate');
78         Hook::register('head', __FILE__, 'saml_head');
79         Hook::register('footer', __FILE__, 'saml_footer');
80 }
81
82 function saml_head(App $a, string &$body)
83 {
84         DI::page()->registerStylesheet(__DIR__ . '/saml.css');
85 }
86
87 function saml_footer(App $a, string &$body)
88 {
89         $fragment = addslashes(BBCode::convert(DI::config()->get('saml', 'settings_statement')));
90         $body .= <<<EOL
91 <script>
92 var target=$("#settings-nickname-desc");
93 if (target.length) { target.append("<p>$fragment</p>"); }
94 </script>
95 EOL;
96 }
97
98 function saml_is_configured()
99 {
100         return
101                 DI::config()->get('saml', 'idp_id') &&
102                 DI::config()->get('saml', 'client_id') &&
103                 DI::config()->get('saml', 'sso_url') &&
104                 DI::config()->get('saml', 'slo_request_url') &&
105                 DI::config()->get('saml', 'slo_response_url') &&
106                 DI::config()->get('saml', 'sp_key') &&
107                 DI::config()->get('saml', 'sp_cert') &&
108                 DI::config()->get('saml', 'idp_cert');
109 }
110
111 function saml_sso_initiate(App $a, array &$b)
112 {
113         if (!saml_is_configured()) {
114                 Logger::warning('SAML SSO tried to trigger, but the SAML addon is not configured yet!');
115                 return;
116         }
117
118         $auth = new \OneLogin\Saml2\Auth(saml_settings());
119         $ssoBuiltUrl = $auth->login(null, [], false, false, true);
120         $_SESSION['AuthNRequestID'] = $auth->getLastRequestID();
121         header('Pragma: no-cache');
122         header('Cache-Control: no-cache, must-revalidate');
123         header('Location: ' . $ssoBuiltUrl);
124         exit();
125 }
126
127 function saml_sso_reply(App $a)
128 {
129         $auth = new \OneLogin\Saml2\Auth(saml_settings());
130         $requestID = null;
131
132         if (isset($_SESSION) && isset($_SESSION['AuthNRequestID'])) {
133                 $requestID = $_SESSION['AuthNRequestID'];
134         }
135
136         $auth->processResponse($requestID);
137         unset($_SESSION['AuthNRequestID']);
138
139         $errors = $auth->getErrors();
140
141         if (!empty($errors)) {
142                 echo 'Errors encountered.';
143                 Logger::error(implode(', ', $errors));
144                 exit();
145         }
146
147         if (!$auth->isAuthenticated()) {
148                 echo 'Not authenticated';
149                 exit();
150         }
151
152         $username = $auth->getNameId();
153         $email = $auth->getAttributeWithFriendlyName('email')[0];
154         $name = $auth->getAttributeWithFriendlyName('givenName')[0];
155         $last_name = $auth->getAttributeWithFriendlyName('surname')[0];
156
157         if (strlen($last_name)) {
158                 $name .= " $last_name";
159         }
160
161         if (!DBA::exists('user', ['nickname' => $username])) {
162                 $user = saml_create_user($username, $email, $name);
163         } else {
164                 $user = User::getByNickname($username);
165         }
166
167         if (!empty($user['uid'])) {
168                 DI::auth()->setForUser($a, $user);
169         }
170
171         if (isset($_POST['RelayState']) && Utils::getSelfURL() != $_POST['RelayState']) {
172                 $auth->redirectTo($_POST['RelayState']);
173         }
174 }
175
176 function saml_slo_initiate(App $a, array &$b)
177 {
178         if (!saml_is_configured()) {
179                 Logger::warning('SAML SLO tried to trigger, but the SAML addon is not configured yet!');
180                 return;
181         }
182
183         $auth = new \OneLogin\Saml2\Auth(saml_settings());
184
185         $sloBuiltUrl = $auth->logout();
186         $_SESSION['LogoutRequestID'] = $auth->getLastRequestID();
187         header('Pragma: no-cache');
188         header('Cache-Control: no-cache, must-revalidate');
189         header('Location: ' . $sloBuiltUrl);
190         exit();
191 }
192
193 function saml_slo_reply()
194 {
195         $auth = new \OneLogin\Saml2\Auth(saml_settings());
196
197         if (isset($_SESSION) && isset($_SESSION['LogoutRequestID'])) {
198                 $requestID = $_SESSION['LogoutRequestID'];
199         } else {
200                 $requestID = null;
201         }
202
203         $auth->processSLO(false, $requestID);
204
205         $errors = $auth->getErrors();
206
207         if (empty($errors)) {
208                 $auth->redirectTo(DI::baseUrl());
209         } else {
210                 Logger::error(implode(', ', $errors));
211         }
212 }
213
214 function saml_input($key, $label, $description)
215 {
216         return [
217                 '$' . $key => [
218                         $key,
219                         $label,
220                         DI::config()->get('saml', $key),
221                         $description,
222                         true, // all the fields are required
223                 ]
224         ];
225 }
226
227 function saml_addon_admin(App $a, string &$o)
228 {
229         $form =
230                 saml_input(
231                         'settings_statement',
232                         DI::l10n()->t('Settings statement'),
233                         DI::l10n()->t('A statement on the settings page explaining where the user should go to change '
234                                         . 'their e-mail and password. BBCode allowed.')
235                 ) +
236                 saml_input(
237                         'idp_id',
238                         DI::l10n()->t('IdP ID'),
239                         DI::l10n()->t('Identity provider (IdP) entity URI (e.g., https://example.com/auth/realms/user).')
240                 ) +
241                 saml_input(
242                         'client_id',
243                         DI::l10n()->t('Client ID'),
244                         DI::l10n()->t('Identifier assigned to client by the identity provider (IdP).')
245                 ) +
246                 saml_input(
247                         'sso_url',
248                         DI::l10n()->t('IdP SSO URL'),
249                         DI::l10n()->t('The URL for your identity provider\'s SSO endpoint.')
250                 ) +
251                 saml_input(
252                         'slo_request_url',
253                         DI::l10n()->t('IdP SLO request URL'),
254                         DI::l10n()->t('The URL for your identity provider\'s SLO request endpoint.')
255                 ) +
256                 saml_input(
257                         'slo_response_url',
258                         DI::l10n()->t('IdP SLO response URL'),
259                         DI::l10n()->t('The URL for your identity provider\'s SLO response endpoint.')
260                 ) +
261                 saml_input(
262                         'sp_key',
263                         DI::l10n()->t('SP private key'),
264                         DI::l10n()->t('The private key the addon should use to authenticate.')
265                 ) +
266                 saml_input(
267                         'sp_cert',
268                         DI::l10n()->t('SP certificate'),
269                         DI::l10n()->t('The certficate for the addon\'s private key.')
270                 ) +
271                 saml_input(
272                         'idp_cert',
273                         DI::l10n()->t('IdP certificate'),
274                         DI::l10n()->t('The x509 certficate for your identity provider.')
275                 ) +
276                 [
277                         '$submit'  => DI::l10n()->t('Save Settings'),
278                 ];
279         $t = Renderer::getMarkupTemplate('admin.tpl', 'addon/saml/');
280         $o = Renderer::replaceMacros($t, $form);
281 }
282
283 function saml_addon_admin_post(App $a)
284 {
285         $set = function ($key) {
286                 $val = (!empty($_POST[$key]) ? trim($_POST[$key]) : '');
287                 DI::config()->set('saml', $key, $val);
288         };
289         $set('idp_id');
290         $set('client_id');
291         $set('sso_url');
292         $set('slo_request_url');
293         $set('slo_response_url');
294         $set('sp_key');
295         $set('sp_cert');
296         $set('idp_cert');
297         $set('settings_statement');
298 }
299
300 function saml_create_user($username, $email, $name)
301 {
302         if (!strlen($email) || !strlen($name)) {
303                 Logger::error('Could not create user: no email or username given.');
304                 return false;
305         }
306
307         try {
308                 $strong = false;
309                 $bytes = openssl_random_pseudo_bytes(intval(ceil(PW_LEN * 0.75)), $strong);
310
311                 if (!$strong) {
312                         throw new Exception('Strong algorithm not available for PRNG.');
313                 }
314
315                 $user = User::create([
316                         'username' => $name,
317                         'nickname' => $username,
318                         'email' => $email,
319                         'password' => base64_encode($bytes), // should be at least PW_LEN long
320                         'verified' => true
321                 ]);
322
323                 return $user;
324         } catch (Exception $e) {
325                 Logger::error(
326                         'Exception while creating user',
327                         [
328                                 'username'  => $username,
329                                 'email'  => $email,
330                                 'name'    => $name,
331                                 'exception' => $e->getMessage(),
332                                 'trace'  => $e->getTraceAsString()
333                         ]
334                 );
335
336                 return false;
337         }
338 }
339
340 function saml_settings()
341 {
342         return [
343
344                 // If 'strict' is True, then the PHP Toolkit will reject unsigned
345                 // or unencrypted messages if it expects them to be signed or encrypted.
346                 // Also it will reject the messages if the SAML standard is not strictly
347                 // followed: Destination, NameId, Conditions ... are validated too.
348                 // Should never be set to anything else in production!
349                 'strict' => true,
350
351                 // Enable debug mode (to print errors).
352                 'debug' => false,
353
354                 // Set a BaseURL to be used instead of try to guess
355                 // the BaseURL of the view that process the SAML Message.
356                 // Ex http://sp.example.com/
357                 //      http://example.com/sp/
358                 'baseurl' => DI::baseUrl() . '/saml',
359
360                 // Service Provider Data that we are deploying.
361                 'sp' => [
362
363                         // Identifier of the SP entity  (must be a URI)
364                         'entityId' => DI::config()->get('saml', 'client_id'),
365
366                         // Specifies info about where and how the <AuthnResponse> message MUST be
367                         // returned to the requester, in this case our SP.
368                         'assertionConsumerService' => [
369
370                                 // URL Location where the <Response> from the IdP will be returned
371                                 'url' => DI::baseUrl() . '/saml/sso',
372
373                                 // SAML protocol binding to be used when returning the <Response>
374                                 // message. OneLogin Toolkit supports this endpoint for the
375                                 // HTTP-POST binding only.
376                                 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
377                         ],
378
379                         // If you need to specify requested attributes, set a
380                         // attributeConsumingService. nameFormat, attributeValue and
381                         // friendlyName can be omitted
382                         'attributeConsumingService'=> [
383                                 'serviceName' => 'Friendica SAML SSO and SLO Addon',
384                                 'serviceDescription' => 'SLO and SSO support for Friendica',
385                                 'requestedAttributes' => [
386                                         [
387                                                 'uid' => '',
388                                                 'isRequired' => false,
389                                         ]
390                                 ]
391                         ],
392
393                         // Specifies info about where and how the <Logout Response> message MUST be
394                         // returned to the requester, in this case our SP.
395                         'singleLogoutService' => [
396
397                                 // URL Location where the <Response> from the IdP will be returned
398                                 'url' => DI::baseUrl() . '/saml/slo',
399
400                                 // SAML protocol binding to be used when returning the <Response>
401                                 // message. OneLogin Toolkit supports the HTTP-Redirect binding
402                                 // only for this endpoint.
403                                 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
404                         ],
405
406                         // Specifies the constraints on the name identifier to be used to
407                         // represent the requested subject.
408                         // Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported.
409                         'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',
410
411                         // Usually x509cert and privateKey of the SP are provided by files placed at
412                         // the certs folder. But we can also provide them with the following parameters
413                         'x509cert' => DI::config()->get('saml', 'sp_cert'),
414                         'privateKey' => DI::config()->get('saml', 'sp_key'),
415                 ],
416
417                 // Identity Provider Data that we want connected with our SP.
418                 'idp' => [
419
420                         // Identifier of the IdP entity  (must be a URI)
421                         'entityId' => DI::config()->get('saml', 'idp_id'),
422
423                         // SSO endpoint info of the IdP. (Authentication Request protocol)
424                         'singleSignOnService' => [
425
426                                 // URL Target of the IdP where the Authentication Request Message
427                                 // will be sent.
428                                 'url' => DI::config()->get('saml', 'sso_url'),
429
430                                 // SAML protocol binding to be used when returning the <Response>
431                                 // message. OneLogin Toolkit supports the HTTP-Redirect binding
432                                 // only for this endpoint.
433                                 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
434                         ],
435
436                         // SLO endpoint info of the IdP.
437                         'singleLogoutService' => [
438
439                                 // URL Location of the IdP where SLO Request will be sent.
440                                 'url' => DI::config()->get('saml', 'slo_request_url'),
441
442                                 // URL location of the IdP where SLO Response will be sent (ResponseLocation)
443                                 // if not set, url for the SLO Request will be used
444                                 'responseUrl' => DI::config()->get('saml', 'slo_response_url'),
445
446                                 // SAML protocol binding to be used when returning the <Response>
447                                 // message. OneLogin Toolkit supports the HTTP-Redirect binding
448                                 // only for this endpoint.
449                                 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
450                         ],
451
452                         // Public x509 certificate of the IdP
453                         'x509cert' => DI::config()->get('saml', 'idp_cert'),
454                 ],
455                 'security' => [
456                         'wantXMLValidation' => false,
457
458                         // Indicates whether the <samlp:AuthnRequest> messages sent by this SP
459                         // will be signed.  [Metadata of the SP will offer this info]
460                         'authnRequestsSigned' => true,
461
462                         // Indicates whether the <samlp:logoutRequest> messages sent by this SP
463                         // will be signed.
464                         'logoutRequestSigned' => true,
465
466                         // Indicates whether the <samlp:logoutResponse> messages sent by this SP
467                         // will be signed.
468                         'logoutResponseSigned' => true,
469
470                         // Sign the Metadata
471                         'signMetadata' => true,
472                 ]
473         ];
474 }