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