Skip to content

Commit 3a17ba2

Browse files
committed
Started using OneLogin SAML lib directly
- Aligned and formatted config options. - Provided way to override onelogin lib options if required. - Added endpoints in core bookstack routes. - Provided way to debug details provided by idp and formatted by bookstack. - Started on test work - Handled case of email address already in use.
1 parent 9bba846 commit 3a17ba2

File tree

21 files changed

+442
-381
lines changed

21 files changed

+442
-381
lines changed

app/Auth/Access/Saml2Service.php

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
use BookStack\Auth\User;
44
use BookStack\Auth\UserRepo;
5+
use BookStack\Exceptions\JsonDebugException;
56
use BookStack\Exceptions\SamlException;
67
use Illuminate\Support\Str;
8+
use OneLogin\Saml2\Auth;
9+
use OneLogin\Saml2\Error;
710

811
/**
912
* Class Saml2Service
@@ -21,10 +24,119 @@ class Saml2Service extends ExternalAuthService
2124
*/
2225
public function __construct(UserRepo $userRepo, User $user)
2326
{
24-
$this->config = config('services.saml');
27+
$this->config = config('saml2');
2528
$this->userRepo = $userRepo;
2629
$this->user = $user;
27-
$this->enabled = config('saml2_settings.enabled') === true;
30+
$this->enabled = config('saml2.enabled') === true;
31+
}
32+
33+
/**
34+
* Initiate a login flow.
35+
* @throws \OneLogin\Saml2\Error
36+
*/
37+
public function login(): array
38+
{
39+
$toolKit = $this->getToolkit();
40+
$returnRoute = url('/saml2/acs');
41+
return [
42+
'url' => $toolKit->login($returnRoute, [], false, false, true),
43+
'id' => $toolKit->getLastRequestID(),
44+
];
45+
}
46+
47+
/**
48+
* Process the ACS response from the idp and return the
49+
* matching, or new if registration active, user matched to the idp.
50+
* Returns null if not authenticated.
51+
* @throws Error
52+
* @throws SamlException
53+
* @throws \OneLogin\Saml2\ValidationError
54+
* @throws JsonDebugException
55+
*/
56+
public function processAcsResponse(?string $requestId): ?User
57+
{
58+
$toolkit = $this->getToolkit();
59+
$toolkit->processResponse($requestId);
60+
$errors = $toolkit->getErrors();
61+
62+
if (is_null($requestId)) {
63+
throw new SamlException(trans('errors.saml_invalid_response_id'));
64+
}
65+
66+
if (!empty($errors)) {
67+
throw new Error(
68+
'Invalid ACS Response: '.implode(', ', $errors)
69+
);
70+
}
71+
72+
if (!$toolkit->isAuthenticated()) {
73+
return null;
74+
}
75+
76+
$attrs = $toolkit->getAttributes();
77+
$id = $toolkit->getNameId();
78+
79+
return $this->processLoginCallback($id, $attrs);
80+
}
81+
82+
/**
83+
* Get the metadata for this service provider.
84+
* @throws Error
85+
*/
86+
public function metadata(): string
87+
{
88+
$toolKit = $this->getToolkit();
89+
$settings = $toolKit->getSettings();
90+
$metadata = $settings->getSPMetadata();
91+
$errors = $settings->validateMetadata($metadata);
92+
93+
if (!empty($errors)) {
94+
throw new Error(
95+
'Invalid SP metadata: '.implode(', ', $errors),
96+
Error::METADATA_SP_INVALID
97+
);
98+
}
99+
100+
return $metadata;
101+
}
102+
103+
/**
104+
* Load the underlying Onelogin SAML2 toolkit.
105+
* @throws \OneLogin\Saml2\Error
106+
*/
107+
protected function getToolkit(): Auth
108+
{
109+
$settings = $this->config['onelogin'];
110+
$overrides = $this->config['onelogin_overrides'] ?? [];
111+
112+
if ($overrides && is_string($overrides)) {
113+
$overrides = json_decode($overrides, true);
114+
}
115+
116+
$spSettings = $this->loadOneloginServiceProviderDetails();
117+
$settings = array_replace_recursive($settings, $spSettings, $overrides);
118+
return new Auth($settings);
119+
}
120+
121+
/**
122+
* Load dynamic service provider options required by the onelogin toolkit.
123+
*/
124+
protected function loadOneloginServiceProviderDetails(): array
125+
{
126+
$spDetails = [
127+
'entityId' => url('/saml2/metadata'),
128+
'assertionConsumerService' => [
129+
'url' => url('/saml2/acs'),
130+
],
131+
'singleLogoutService' => [
132+
'url' => url('/saml2/sls')
133+
],
134+
];
135+
136+
return [
137+
'baseurl' => url('/saml2'),
138+
'sp' => $spDetails
139+
];
28140
}
29141

30142
/**
@@ -155,7 +267,11 @@ protected function registerUser(array $userDetails): User
155267
'email_confirmed' => true,
156268
];
157269

158-
// TODO - Handle duplicate email address scenario
270+
$existingUser = $this->user->newQuery()->where('email', '=', $userDetails['email'])->first();
271+
if ($existingUser) {
272+
throw new SamlException(trans('errors.saml_email_exists', ['email' => $userDetails['email']]));
273+
}
274+
159275
$user = $this->user->forceCreate($userData);
160276
$this->userRepo->attachDefaultRole($user);
161277
$this->userRepo->downloadAndAssignUserAvatar($user);
@@ -167,7 +283,7 @@ protected function registerUser(array $userDetails): User
167283
*/
168284
protected function getOrRegisterUser(array $userDetails): ?User
169285
{
170-
$isRegisterEnabled = config('services.saml.auto_register') === true;
286+
$isRegisterEnabled = $this->config['auto_register'] === true;
171287
$user = $this->user
172288
->where('external_auth_id', $userDetails['external_id'])
173289
->first();
@@ -183,12 +299,20 @@ protected function getOrRegisterUser(array $userDetails): ?User
183299
* Process the SAML response for a user. Login the user when
184300
* they exist, optionally registering them automatically.
185301
* @throws SamlException
302+
* @throws JsonDebugException
186303
*/
187304
public function processLoginCallback(string $samlID, array $samlAttributes): User
188305
{
189306
$userDetails = $this->getUserDetails($samlID, $samlAttributes);
190307
$isLoggedIn = auth()->check();
191308

309+
if ($this->config['dump_user_details']) {
310+
throw new JsonDebugException([
311+
'attrs_from_idp' => $samlAttributes,
312+
'attrs_after_parsing' => $userDetails,
313+
]);
314+
}
315+
192316
if ($isLoggedIn) {
193317
throw new SamlException(trans('errors.saml_already_logged_in'), '/login');
194318
}

app/Config/app.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@
105105
Intervention\Image\ImageServiceProvider::class,
106106
Barryvdh\DomPDF\ServiceProvider::class,
107107
Barryvdh\Snappy\ServiceProvider::class,
108-
Aacotroneo\Saml2\Saml2ServiceProvider::class,
109108

110109
// BookStack replacement service providers (Extends Laravel)
111110
BookStack\Providers\PaginationServiceProvider::class,

app/Config/saml2.php

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
return [
4+
5+
// Display name, shown to users, for SAML2 option
6+
'name' => env('SAML2_NAME', 'SSO'),
7+
// Toggle whether the SAML2 option is active
8+
'enabled' => env('SAML2_ENABLED', false),
9+
// Enable registration via SAML2 authentication
10+
'auto_register' => env('SAML2_AUTO_REGISTER', true),
11+
12+
// Dump user details after a login request for debugging purposes
13+
'dump_user_details' => env('SAML2_DUMP_USER_DETAILS', false),
14+
15+
// Attribute, within a SAML response, to find the user's email address
16+
'email_attribute' => env('SAML2_EMAIL_ATTRIBUTE', 'email'),
17+
// Attribute, within a SAML response, to find the user's display name
18+
'display_name_attributes' => explode('|', env('SAML2_DISPLAY_NAME_ATTRIBUTES', 'username')),
19+
// Attribute, within a SAML response, to use to connect a BookStack user to the SAML user.
20+
'external_id_attribute' => env('SAML2_EXTERNAL_ID_ATTRIBUTE', null),
21+
22+
// Group sync options
23+
// Enable syncing, upon login, of SAML2 groups to BookStack groups
24+
'user_to_groups' => env('SAML2_USER_TO_GROUPS', false),
25+
// Attribute, within a SAML response, to find group names on
26+
'group_attribute' => env('SAML2_GROUP_ATTRIBUTE', 'group'),
27+
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
28+
'remove_from_groups' => env('SAML2_REMOVE_FROM_GROUPS', false),
29+
30+
// Overrides, in JSON format, to the configuration passed to underlying onelogin library.
31+
'onelogin_overrides' => env('SAML2_ONELOGIN_OVERRIDES', null),
32+
33+
34+
'onelogin' => [
35+
// If 'strict' is True, then the PHP Toolkit will reject unsigned
36+
// or unencrypted messages if it expects them signed or encrypted
37+
// Also will reject the messages if not strictly follow the SAML
38+
// standard: Destination, NameId, Conditions ... are validated too.
39+
'strict' => true,
40+
41+
// Enable debug mode (to print errors)
42+
'debug' => env('APP_DEBUG', false),
43+
44+
// Set a BaseURL to be used instead of try to guess
45+
// the BaseURL of the view that process the SAML Message.
46+
// Ex. http://sp.example.com/
47+
// http://example.com/sp/
48+
'baseurl' => null,
49+
50+
// Service Provider Data that we are deploying
51+
'sp' => [
52+
// Identifier of the SP entity (must be a URI)
53+
'entityId' => '',
54+
55+
// Specifies info about where and how the <AuthnResponse> message MUST be
56+
// returned to the requester, in this case our SP.
57+
'assertionConsumerService' => [
58+
// URL Location where the <Response> from the IdP will be returned
59+
'url' => '',
60+
// SAML protocol binding to be used when returning the <Response>
61+
// message. Onelogin Toolkit supports for this endpoint the
62+
// HTTP-POST binding only
63+
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
64+
],
65+
66+
// Specifies info about where and how the <Logout Response> message MUST be
67+
// returned to the requester, in this case our SP.
68+
'singleLogoutService' => [
69+
// URL Location where the <Response> from the IdP will be returned
70+
'url' => '',
71+
// SAML protocol binding to be used when returning the <Response>
72+
// message. Onelogin Toolkit supports for this endpoint the
73+
// HTTP-Redirect binding only
74+
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
75+
],
76+
77+
// Specifies constraints on the name identifier to be used to
78+
// represent the requested subject.
79+
// Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported
80+
'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
81+
// Usually x509cert and privateKey of the SP are provided by files placed at
82+
// the certs folder. But we can also provide them with the following parameters
83+
'x509cert' => '',
84+
'privateKey' => '',
85+
],
86+
// Identity Provider Data that we want connect with our SP
87+
'idp' => [
88+
// Identifier of the IdP entity (must be a URI)
89+
'entityId' => env('SAML2_IDP_ENTITYID', null),
90+
// SSO endpoint info of the IdP. (Authentication Request protocol)
91+
'singleSignOnService' => [
92+
// URL Target of the IdP where the SP will send the Authentication Request Message
93+
'url' => env('SAML2_IDP_SSO', null),
94+
// SAML protocol binding to be used when returning the <Response>
95+
// message. Onelogin Toolkit supports for this endpoint the
96+
// HTTP-Redirect binding only
97+
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
98+
],
99+
// SLO endpoint info of the IdP.
100+
'singleLogoutService' => [
101+
// URL Location of the IdP where the SP will send the SLO Request
102+
'url' => env('SAML2_IDP_SLO', null),
103+
// URL location of the IdP where the SP will send the SLO Response (ResponseLocation)
104+
// if not set, url for the SLO Request will be used
105+
'responseUrl' => '',
106+
// SAML protocol binding to be used when returning the <Response>
107+
// message. Onelogin Toolkit supports for this endpoint the
108+
// HTTP-Redirect binding only
109+
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
110+
],
111+
// Public x509 certificate of the IdP
112+
'x509cert' => env('SAML2_IDP_x509', null),
113+
/*
114+
* Instead of use the whole x509cert you can use a fingerprint in
115+
* order to validate the SAMLResponse, but we don't recommend to use
116+
* that method on production since is exploitable by a collision
117+
* attack.
118+
* (openssl x509 -noout -fingerprint -in "idp.crt" to generate it,
119+
* or add for example the -sha256 , -sha384 or -sha512 parameter)
120+
*
121+
* If a fingerprint is provided, then the certFingerprintAlgorithm is required in order to
122+
* let the toolkit know which Algorithm was used. Possible values: sha1, sha256, sha384 or sha512
123+
* 'sha1' is the default value.
124+
*/
125+
// 'certFingerprint' => '',
126+
// 'certFingerprintAlgorithm' => 'sha1',
127+
/* In some scenarios the IdP uses different certificates for
128+
* signing/encryption, or is under key rollover phase and more
129+
* than one certificate is published on IdP metadata.
130+
* In order to handle that the toolkit offers that parameter.
131+
* (when used, 'x509cert' and 'certFingerprint' values are
132+
* ignored).
133+
*/
134+
// 'x509certMulti' => array(
135+
// 'signing' => array(
136+
// 0 => '<cert1-string>',
137+
// ),
138+
// 'encryption' => array(
139+
// 0 => '<cert2-string>',
140+
// )
141+
// ),
142+
],
143+
],
144+
145+
];

0 commit comments

Comments
 (0)