]> git.mxchange.org Git - friendica-addons.git/blob - saml/saml.php
Log SAML errors in saml_sso_reply
[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: 0.0
6  * Author: Ryan <https://friendica.verya.pe/profile/ryan>
7  */
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;
13 use Friendica\DI;
14 use Friendica\Model\User;
15 use Friendica\Util\Strings;
16 use OneLogin\Saml2\Auth;
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($a) {}
23
24 function saml_init($a) {
25     if ($a->argc < 2) return;
26
27     switch ($a->argv[1]) {
28         case "metadata.xml":
29             saml_metadata();
30             break;
31         case "sso":
32             saml_sso_reply($a);
33             break;
34         case "slo":
35             saml_slo_reply();
36             break;
37         case "moo":
38             echo DI::baseUrl();
39             echo $_SERVER['REQUEST_URI'];
40             break;
41     }
42     exit();
43 }
44
45 function saml_metadata() {
46     try {
47         $settings = new \OneLogin\Saml2\Settings(saml_settings());
48         $metadata = $settings->getSPMetadata();
49         $errors = $settings->validateMetadata($metadata);
50
51         if (empty($errors)) {
52             header('Content-Type: text/xml');
53             echo $metadata;
54         } else {
55             throw new \OneLogin\Saml2\Error(
56                 'Invalid SP metadata: '.implode(', ', $errors),
57                 \OneLogin\Saml2\Error::METADATA_SP_INVALID
58             );
59         }
60     } catch (Exception $e) {
61         Logger::error($e->getMessage());
62     }
63 }
64
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');
70 }
71
72 function saml_head(&$a, &$b) {
73     DI::page()->registerStylesheet(__DIR__ . '/saml.css');
74 }
75
76 function saml_footer(&$a, &$b) {
77     $fragment = addslashes(DI::config()->get('saml', 'settings_statement'));
78     $b .= <<<EOL
79 <script>
80 var target=$("#settings-nickname-desc");
81 if (target.length) { target.append("$fragment"); }
82 </script>
83 EOL;
84 }
85
86 function saml_is_configured() {
87     return
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');
96
97 }
98
99 function saml_sso_initiate(&$a, &$b) {
100     if (!saml_is_configured()) return;
101
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);
108     exit();
109 }
110
111 function saml_sso_reply($a) {
112     $auth = new \OneLogin\Saml2\Auth(saml_settings());
113     $requestID = null;
114
115     if (isset($_SESSION) && isset($_SESSION['AuthNRequestID'])) {
116         $requestID = $_SESSION['AuthNRequestID'];
117     }
118
119     $auth->processResponse($requestID);
120     unset($_SESSION['AuthNRequestID']);
121
122     $errors = $auth->getErrors();
123
124     if (!empty($errors)) {
125         echo "Errors encountered.";
126         Logger::error(implode(', ', $errors));
127         exit();
128     }
129
130     if (!$auth->isAuthenticated()) {
131         echo "Not authenticated";
132         exit();
133     }
134
135     $username = $auth->getNameId();
136     $email = $auth->getAttributeWithFriendlyName('email')[0];
137     $name = $auth->getAttributeWithFriendlyName('givenName')[0];
138     $last_name = $auth->getAttributeWithFriendlyName('surname')[0];
139
140     if (strlen($last_name)) {
141         $name .= " $last_name";
142     }
143
144     if (!DBA::exists('user', ['nickname' => $username])) {
145         $user = saml_create_user($username, $email, $name);
146     } else {
147         $user = User::getByNickname($username);
148     }
149
150     if (!empty($user['uid'])) {
151         DI::auth()->setForUser($a, $user);
152     }
153
154     if (isset($_POST['RelayState'])
155         && \OneLogin\Saml2\Utils::getSelfURL() != $_POST['RelayState'])
156     {
157         $auth->redirectTo($_POST['RelayState']);
158     }
159 }
160
161 function saml_slo_initiate(&$a, &$b) {
162     $auth = new \OneLogin\Saml2\Auth(saml_settings());
163
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);
169     exit();
170 }
171
172 function saml_slo_reply() {
173     $auth = new \OneLogin\Saml2\Auth(saml_settings());
174
175     if (isset($_SESSION) && isset($_SESSION['LogoutRequestID'])) {
176         $requestID = $_SESSION['LogoutRequestID'];
177     } else {
178         $requestID = null;
179     }
180
181     $auth->processSLO(false, $requestID);
182
183     $errors = $auth->getErrors();
184
185     if (empty($errors)) {
186         $auth->redirectTo(DI::baseUrl());
187     } else {
188         Logger::error(implode(', ', $errors));
189     }
190 }
191
192 function saml_input($key, $label, $description) {
193     return [
194         '$' . $key => [
195             $key,
196             DI::l10n()->t($label),
197             DI::config()->get('saml', $key),
198             DI::l10n()->t($description),
199         true, // all the fields are required
200         ]
201     ];
202 }
203
204 function saml_addon_admin (&$a, &$o) {
205     $form = 
206         saml_input(
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.'
210         ) +
211         saml_input(
212             'idp_id',
213             'IdP ID',
214             'Identity provider (IdP) entity URI (e.g., https://example.com/auth/realms/user).'
215         ) +
216         saml_input(
217             'client_id',
218             'Client ID',
219             'Identifier assigned to client by the identity provider (IdP).'
220         ) +
221         saml_input(
222             'sso_url',
223             'IdP SSO URL',
224             'The URL for your identity provider\'s SSO endpoint.'
225         ) +
226         saml_input(
227             'slo_request_url',
228             'IdP SLO request URL',
229             'The URL for your identity provider\'s SLO request endpoint.'
230         ) +
231         saml_input(
232             'slo_response_url',
233             'IdP SLO response URL',
234             'The URL for your identity provider\'s SLO response endpoint.'
235         ) +
236         saml_input(
237             'sp_key',
238             'SP private key',
239             'The private key the addon should use to authenticate.'
240         ) +
241         saml_input(
242             'sp_cert',
243             'SP certificate',
244             'The certficate for the addon\'s private key.'
245         ) +
246         saml_input(
247             'idp_cert',
248             'IdP certificate',
249             'The x509 certficate for your identity provider.'
250         ) +
251         [
252             '$submit'  => DI::l10n()->t('Save Settings'),
253         ];
254     $t = Renderer::getMarkupTemplate( "admin.tpl", "addon/saml/" );
255     $o = Renderer::replaceMacros( $t, $form);
256 }
257
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);
262     };
263     $safeset('idp_id');
264     $safeset('client_id');
265     $safeset('sso_url');
266     $safeset('slo_request_url');
267     $safeset('slo_response_url');
268     $safeset('sp_key');
269     $safeset('sp_cert');
270     $safeset('idp_cert');
271
272     // Not using safeset here since settings_statement is *meant* to include HTML tags.
273     DI::config()->set('saml', 'settings_statement', $_POST['settings_statement']);
274 }
275
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.');
279         return false;
280     }
281
282     try {
283         $strong = false;
284         $bytes = openssl_random_pseudo_bytes(intval(ceil(PW_LEN * 0.75)), $strong);
285
286         if (!$strong) {
287             throw new Exception('Strong algorithm not available for PRNG.');
288         }
289
290         $user = User::create([
291             'username' => $name,
292             'nickname' => $username,
293             'email'    => $email,
294             'password' => base64_encode($bytes), // should be at least PW_LEN long
295             'verified' => true
296         ]);
297
298         return $user;
299
300     } catch (Exception $e) {
301         Logger::error(
302             'Exception while creating user',
303             [
304                 'username'  => $username,
305                 'email'     => $email,
306                 'name'      => $name,
307                 'exception' => $e->getMessage(),
308                 'trace'     => $e->getTraceAsString()
309             ]);
310
311         return false;
312     }
313 }
314
315 function saml_settings() {
316     return array(
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!
322         'strict' => true,
323
324         // Enable debug mode (to print errors).
325         'debug' => false,
326
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",
332
333         // Service Provider Data that we are deploying.
334         'sp' => array(
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',
346             ),
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(
354                     array(
355                     "uid" => "",
356                     "isRequired" => false,
357                     )
358                 )
359             ),
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',
369             ),
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'),
378         ),
379
380         // Identity Provider Data that we want connected with our SP.
381         'idp' => array(
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
387                 // will be sent.
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',
393             ),
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',
405            ),
406            // Public x509 certificate of the IdP
407            'x509cert' => DI::config()->get('saml','idp_cert'),
408        ),
409        'security' => array (
410                'wantXMLValidation' => false,
411
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,
415
416        // Indicates whether the <samlp:logoutRequest> messages sent by this SP
417        // will be signed.
418        'logoutRequestSigned' => true,
419
420        // Indicates whether the <samlp:logoutResponse> messages sent by this SP
421        // will be signed.
422        'logoutResponseSigned' => true,
423
424        /* Sign the Metadata */
425        'signMetadata' => true,
426        )
427     );
428 }
429 ?>