forked from xibosignage/xibo-cms
/
SAMLAuthentication.php
394 lines (336 loc) · 16 KB
/
SAMLAuthentication.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
<?php
/*
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Middleware;
use OneLogin\Saml2\Auth;
use OneLogin\Saml2\Error;
use OneLogin\Saml2\Settings;
use OneLogin\Saml2\Utils;
use Slim\Http\Response as Response;
use Slim\Http\ServerRequest as Request;
use Xibo\Helper\ApplicationState;
use Xibo\Helper\LogoutTrait;
use Xibo\Helper\Random;
use Xibo\Support\Exception\AccessDeniedException;
use Xibo\Support\Exception\ConfigurationException;
use Xibo\Support\Exception\NotFoundException;
/**
* Class SAMLAuthentication
* @package Xibo\Middleware
*
* Provide SAML authentication to Xibo configured via settings.php.
*/
class SAMLAuthentication extends AuthenticationBase
{
use LogoutTrait;
/**
* @return $this
*/
public function addRoutes()
{
$app = $this->app;
$app->getContainer()->logoutRoute = 'saml.logout';
// Route providing SAML metadata
$app->get('/saml/metadata', function (Request $request, Response $response) {
$settings = new Settings($this->getConfig()->samlSettings, true);
$metadata = $settings->getSPMetadata();
$errors = $settings->validateMetadata($metadata);
if (empty($errors)) {
return $response
->withHeader('Content-Type', 'text/xml')
->write($metadata);
} else {
throw new ConfigurationException(
'Invalid SP metadata: ' . implode(', ', $errors),
Error::METADATA_SP_INVALID
);
}
});
// SAML Login
$app->get('/saml/login', function (Request $request, Response $response) {
// Initiate SAML SSO
$auth = new Auth($this->getConfig()->samlSettings);
return $auth->login();
});
// SAML Logout
$app->get('/saml/logout', function (Request $request, Response $response) {
return $this->samlLogout($request, $response);
})->setName('saml.logout');
// SAML Assertion Consumer Endpoint
$app->post('/saml/acs', function (Request $request, Response $response) {
// Log some interesting things
$this->getLog()->debug('Arrived at the ACS route with own URL: ' . Utils::getSelfRoutedURLNoQuery());
// Pull out the SAML settings
$samlSettings = $this->getConfig()->samlSettings;
$auth = new Auth($samlSettings);
$auth->processResponse();
// Check for errors
$errors = $auth->getErrors();
if (!empty($errors)) {
$this->getLog()->error('Single Sign on Failed: ' . implode(', ', $errors)
. '. Last Reason: ' . $auth->getLastErrorReason());
throw new AccessDeniedException(__('Your authentication provider could not log you in.'));
} else {
// Pull out the SAML attributes
$samlAttrs = $auth->getAttributes();
$this->getLog()->debug('SAML attributes: ' . json_encode($samlAttrs));
// How should we look up the user?
$identityField = (isset($samlSettings['workflow']['field_to_identify']))
? $samlSettings['workflow']['field_to_identify']
: 'UserName';
if ($identityField !== 'nameId' && empty($samlAttrs)) {
// We will need some attributes
throw new AccessDeniedException(__('No attributes retrieved from the IdP'));
}
// If appropriate convert the SAML Attributes into userData mapped against the workflow mappings.
$userData = [];
if (isset($samlSettings['workflow']) && isset($samlSettings['workflow']['mapping'])) {
foreach ($samlSettings['workflow']['mapping'] as $key => $value) {
if (!empty($value) && isset($samlAttrs[$value])) {
$userData[$key] = $samlAttrs[$value];
}
}
// If we can't map anything, then we better throw an error
if (empty($userData)) {
throw new AccessDeniedException(__('No attributes could be mapped'));
}
}
// If we're using the nameId as the identity, then we should populate our userData with that value
if ($identityField === 'nameId') {
$userData[$identityField] = $auth->getNameId();
} else {
// Check to ensure that our identity has been populated from attributes successfully
if (!isset($userData[$identityField]) || empty($userData[$identityField])) {
throw new AccessDeniedException(sprintf(__('%s not retrieved from the IdP and required since is the field to identify the user'), $identityField));
}
}
// Try and get the user record.
$user = null;
try {
switch ($identityField) {
case 'nameId':
$user = $this->getUserFactory()->getByName($userData[$identityField]);
break;
case 'UserID':
$user = $this->getUserFactory()->getById($userData[$identityField][0]);
break;
case 'UserName':
$user = $this->getUserFactory()->getByName($userData[$identityField][0]);
break;
case 'email':
$user = $this->getUserFactory()->getByEmail($userData[$identityField][0]);
break;
default:
throw new AccessDeniedException(__('Invalid field_to_identify value. Review settings.'));
}
} catch (NotFoundException $e) {
// User does not exist - this is valid as we might create them JIT.
}
if (!isset($user)) {
if (!isset($samlSettings['workflow']['jit']) || $samlSettings['workflow']['jit'] == false) {
throw new AccessDeniedException(__('User logged at the IdP but the account does not exist in the CMS and Just-In-Time provisioning is disabled'));
} else {
// Provision the user
$user = $this->getEmptyUser();
$user->homeFolderId = 1;
if (isset($userData["UserName"])) {
$user->userName = $userData["UserName"][0];
}
if (isset($userData["email"])) {
$user->email = $userData["email"][0];
}
if (isset($userData["usertypeid"])) {
$user->userTypeId = $userData["usertypeid"][0];
} else {
$user->userTypeId = 3;
}
// Xibo requires a password, generate a random one (it won't ever be used by SAML)
$password = Random::generateString(20);
$user->setNewPassword($password);
// Home page
if (isset($samlSettings['workflow']['homePage'])) {
try {
$user->homePageId = $this->getUserGroupFactory()->getHomepageByName($samlSettings['workflow']['homePage'])->homepage;
} catch (NotFoundException $exception) {
$this->getLog()->info(sprintf('Provided homepage %s, does not exist, setting the icondashboard.view as homepage', $samlSettings['workflow']['homePage']));
$user->homePageId = 'icondashboard.view';
}
} else {
$user->homePageId = 'icondashboard.view';
}
// Library Quota
if (isset($samlSettings['workflow']['libraryQuota'])) {
$user->libraryQuota = $samlSettings['workflow']['libraryQuota'];
} else {
$user->libraryQuota = 0;
}
// Match references
if (isset($samlSettings['workflow']['ref1']) && isset($userData['ref1'])) {
$user->ref1 = $userData['ref1'];
}
if (isset($samlSettings['workflow']['ref2']) && isset($userData['ref2'])) {
$user->ref2 = $userData['ref2'];
}
if (isset($samlSettings['workflow']['ref3']) && isset($userData['ref3'])) {
$user->ref3 = $userData['ref3'];
}
if (isset($samlSettings['workflow']['ref4']) && isset($userData['ref4'])) {
$user->ref4 = $userData['ref4'];
}
if (isset($samlSettings['workflow']['ref5']) && isset($userData['ref5'])) {
$user->ref5 = $userData['ref5'];
}
// Save the user
$user->save();
// Assign the initial group
if (isset($samlSettings['workflow']['group'])) {
$group = $this->getUserGroupFactory()->getByName($samlSettings['workflow']['group']);
} else {
$group = $this->getUserGroupFactory()->getByName('Users');
}
$group->assignUser($user);
$group->save(['validate' => false]);
$this->getLog()->setIpAddress($request->getAttribute('ip_address'));
// Audit Log
$this->getLog()->audit('User', $user->userId, 'User created with SAML workflow', [
'UserName' => $user->userName,
'UserAgent' => $request->getHeader('User-Agent')
]);
}
}
if (isset($user) && $user->userId > 0) {
// Load User
$this->getUser($user->userId, $request->getAttribute('ip_address'));
// Overwrite our stored user with this new object.
$this->setUserForRequest($user);
// Switch Session ID's
$this->getSession()->setIsExpired(0);
$this->getSession()->regenerateSessionId();
$this->getSession()->setUser($user->userId);
$user->touch();
// Audit Log
$this->getLog()->audit('User', $user->userId, 'Login Granted via SAML', [
'UserAgent' => $request->getHeader('User-Agent')
]);
}
// Redirect back to the originally-requested url, if provided
// it is not clear why basename is used here, it seems to be something to do with a logout loop
$params = $request->getParams();
$relayState = $params['RelayState'] ?? null;
$redirect = empty($relayState) || basename($relayState) === 'login'
? $this->getRouteParser()->urlFor('home')
: $relayState;
return $response->withRedirect($redirect);
}
});
// Single Logout Service
$app->map(['GET', 'POST'], '/saml/sls', function (Request $request, Response $response) use ($app) {
// Make request to IDP
$auth = new Auth($app->getContainer()->get('configService')->samlSettings);
try {
$auth->processSLO(false, null, false, function () use ($request) {
// Audit that the IDP has completed this request.
$this->getLog()->setIpAddress($request->getAttribute('ip_address'));
$this->getLog()->audit('User', 0, 'Idp SLO completed', [
'UserAgent' => $request->getHeader('User-Agent')
]);
});
} catch (\Exception $e) {
// Ignored - get with getErrors()
}
$errors = $auth->getErrors();
if (empty($errors)) {
return $response->withRedirect($this->getRouteParser()->urlFor('home'));
} else {
throw new AccessDeniedException('SLO failed. ' . implode(', ', $errors));
}
});
return $this;
}
/**
* @param \Slim\Http\ServerRequest $request
* @param \Slim\Http\Response $response
* @return \Psr\Http\Message\ResponseInterface|\Slim\Http\Response
* @throws \OneLogin\Saml2\Error
*/
public function samlLogout(Request $request, Response $response)
{
$samlSettings = $this->getConfig()->samlSettings;
if (isset($samlSettings['workflow'])
&& isset($samlSettings['workflow']['slo'])
&& $samlSettings['workflow']['slo'] == true
) {
// Complete our own logout flow
$this->completeLogoutFlow(
$this->getUser($_SESSION['userid'], $request->getAttribute('ip_address')),
$this->getSession(),
$this->getLog(),
$request
);
// Initiate SAML SLO
$auth = new Auth($samlSettings);
return $response->withRedirect($auth->logout());
} else {
return $response->withRedirect($this->getRouteParser()->urlFor('logout'));
}
}
/**
* @param Request $request
* @return Response
* @throws \OneLogin\Saml2\Error
*/
public function redirectToLogin(\Psr\Http\Message\ServerRequestInterface $request)
{
if ($this->isAjax($request)) {
return $this->createResponse($request)->withJson(ApplicationState::asRequiresLogin());
} else {
// Initiate SAML SSO
$auth = new Auth($this->getConfig()->samlSettings);
return $this->createResponse($request)->withRedirect($auth->login());
}
}
/** @inheritDoc */
public function getPublicRoutes(\Psr\Http\Message\ServerRequestInterface $request)
{
return array_merge($request->getAttribute('publicRoutes', []), [
'/saml/metadata',
'/saml/login',
'/saml/acs',
'/saml/logout',
'/saml/sls'
]);
}
/** @inheritDoc */
public function shouldRedirectPublicRoute($route)
{
return ($this->getSession()->isExpired()
&& ($route == '/login/ping' || $route == 'clock'))
|| $route == '/login';
}
/** @inheritDoc */
public function addToRequest(\Psr\Http\Message\ServerRequestInterface $request)
{
return $request->withAttribute(
'excludedCsrfRoutes',
array_merge($request->getAttribute('excludedCsrfRoutes', []), ['/saml/acs', '/saml/sls'])
);
}
}