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