SAMLController   A
last analyzed

Complexity

Total Complexity 36

Size/Duplication

Total Lines 299
Duplicated Lines 0 %

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 124
c 5
b 0
f 0
dl 0
loc 299
rs 9.52
wmc 36

7 Methods

Rating   Name   Duplication   Size   Complexity  
A index() 0 3 1
A checkForReplayAttack() 0 28 2
A getForm() 0 3 1
A getLogger() 0 3 1
F acs() 0 150 22
A getRedirect() 0 21 6
A metadata() 0 20 3
1
<?php
2
3
namespace SilverStripe\SAML\Control;
4
5
use Exception;
6
use function gmmktime;
7
use OneLogin\Saml2\Auth;
8
use OneLogin\Saml2\Constants;
9
use OneLogin\Saml2\Utils;
10
use OneLogin\Saml2\Error;
11
use Psr\Log\LoggerInterface;
12
use SilverStripe\Core\Config\Config;
13
use SilverStripe\ORM\ValidationResult;
14
use SilverStripe\SAML\Authenticators\SAMLAuthenticator;
15
use SilverStripe\SAML\Authenticators\SAMLLoginForm;
16
use SilverStripe\SAML\Helpers\SAMLHelper;
17
use SilverStripe\Control\Controller;
18
use SilverStripe\Control\Director;
19
use SilverStripe\Control\HTTPResponse;
20
use SilverStripe\Core\Injector\Injector;
21
use SilverStripe\SAML\Model\SAMLResponse;
22
use SilverStripe\SAML\Services\SAMLConfiguration;
23
use SilverStripe\Security\IdentityStore;
24
use SilverStripe\Security\Member;
25
use SilverStripe\Security\Security;
26
use function uniqid;
27
28
/**
29
 * Class SAMLController
30
 *
31
 * This controller handles serving metadata requests for the identity provider (IdP), as well as handling the creation
32
 * of new users and logging them into SilverStripe after being authenticated at the IdP.
33
 */
34
class SAMLController extends Controller
35
{
36
    /**
37
     * @var array
38
     */
39
    private static $allowed_actions = [
40
        'index',
41
        'acs',
42
        'metadata'
43
    ];
44
45
    public function index()
46
    {
47
        return $this->redirect('/');
48
    }
49
50
    /**
51
     * Assertion Consumer Service
52
     *
53
     * The user gets sent back here after authenticating with the IdP, off-site.
54
     * The earlier redirection to the IdP can be found in the SAMLAuthenticator::authenticate.
55
     *
56
     * After this handler completes, we end up with a rudimentary Member record (which will be created on-the-fly
57
     * if not existent), with the user already logged in. Login triggers memberLoggedIn hooks, which allows
58
     * LDAP side of this module to finish off loading Member data.
59
     *
60
     * @throws Error
61
     * @throws \Psr\Container\NotFoundExceptionInterface
62
     */
63
    public function acs()
64
    {
65
        /** @var Auth $auth */
66
        $auth = Injector::inst()->get(SAMLHelper::class)->getSAMLAuth();
67
        $caughtException = null;
68
69
        // Log both errors (reported by php-saml and thrown as exception) with a common ID for later tracking
70
        $uniqueErrorId = uniqid('SAML-');
71
72
        // Force php-saml module to use the current absolute base URL (e.g. https://www.example.com/saml). This avoids
73
        // errors that we otherwise get when having a multi-directory ACS URL like /saml/acs).
74
        // See https://github.com/onelogin/php-saml/issues/249
75
        Utils::setBaseURL(Controller::join_links($auth->getSettings()->getSPData()['entityId'], 'saml'));
76
77
        // Attempt to process the SAML response. If there are errors during this, log them and redirect to the generic
78
        // error page. Note: This does not necessarily include all SAML errors (e.g. we still need to confirm if the
79
        // user is authenticated after this block
80
        try {
81
            $auth->processResponse();
82
            $error = $auth->getLastErrorReason();
83
        } catch (Exception $e) {
84
            $caughtException = $e;
85
        }
86
87
        // If there was an issue with the SAML response, if it was missing or if the SAML response indicates that they
88
        // aren't authorised, then log the issue and provide a traceable error back to the user via the login form
89
        $hasError = $caughtException || !empty($error);
90
        if ($hasError || !$auth->isAuthenticated() || $this->checkForReplayAttack($auth, $uniqueErrorId)) {
91
            if ($caughtException instanceof Exception) {
92
                $this->getLogger()->error(sprintf(
93
                    '[%s] [code: %s] %s (%s:%s)',
94
                    $uniqueErrorId,
95
                    $e->getCode(),
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $e does not seem to be defined for all execution paths leading up to this point.
Loading history...
96
                    $e->getMessage(),
97
                    $e->getFile(),
98
                    $e->getLine()
99
                ));
100
            }
101
102
            if (!empty($error)) {
103
                $this->getLogger()->error(sprintf('[%s] %s', $uniqueErrorId, $error));
104
            }
105
106
            $this->getForm()->sessionMessage(
107
                _t(
108
                    'SilverStripe\\SAML\\Control\\SAMLController.ERR_SAML_ACS_FAILURE',
109
                    'Unfortunately we couldn\'t log you in. If this continues, please contact your I.T. department'
110
                    . ' with the following reference: {ref}',
111
                    ['ref' => $uniqueErrorId]
112
                ),
113
                ValidationResult::TYPE_ERROR
114
            );
115
116
            // Redirect the user back to the login form to display the generic error message and reference
117
            $this->getRequest()->getSession()->save($this->getRequest());
118
            return $this->redirect('Security/login');
119
        }
120
121
        /**
122
         * If processing reaches here, then the user is authenticated - the rest of this method is just processing their
123
         * legitimate information and configuring their account.
124
         */
125
126
        $helper = SAMLHelper::singleton();
127
128
        // If we expect the NameID to be a binary version of the GUID (ADFS), check that it actually is
129
        // If we are configured not to expect a binary NameID, then we assume it is a direct GUID (Azure AD)
130
        if (Config::inst()->get(SAMLConfiguration::class, 'expect_binary_nameid')) {
131
            $decodedNameId = base64_decode($auth->getNameId());
132
            if (ctype_print($decodedNameId)) {
133
                $this->getForm()->sessionMessage('NameID from IdP is not a binary GUID.', ValidationResult::TYPE_ERROR);
134
                $this->getRequest()->getSession()->save($this->getRequest());
135
                return $this->getRedirect();
136
            }
137
138
            // transform the NameId to guid
139
            $guid = $helper->binToStrGuid($decodedNameId);
140
        } else {
141
            $guid = $auth->getNameId();
142
        }
143
144
        if (!$helper->validGuid($guid)) {
145
            $errorMessage = "Not a valid GUID '{$guid}' received from server.";
146
            $this->getLogger()->error($errorMessage);
147
            $this->getForm()->sessionMessage($errorMessage, ValidationResult::TYPE_ERROR);
148
            $this->getRequest()->getSession()->save($this->getRequest());
149
            return $this->getRedirect();
150
        }
151
152
        $this->extend('updateGuid', $guid);
153
154
        $attributes = $auth->getAttributes();
155
156
        // Allows setups that map GUID (email format) to email {@see SAMLConfiguration::$expose_guid_as_attribute}.
157
        if (Config::inst()->get(SAMLConfiguration::class, 'expose_guid_as_attribute')) {
158
            $attributes['GUID'][0] = $guid;
159
        }
160
161
        $fieldToClaimMap = array_flip(Member::config()->claims_field_mappings);
162
163
        // Write a rudimentary member with basic fields on every login, so that we at least have something
164
        // if there is no further sync (e.g. via LDAP)
165
        $member = Member::get()->filter('GUID', $guid)->limit(1)->first();
166
        if (!($member && $member->exists())
167
            && Config::inst()->get(SAMLConfiguration::class, 'allow_insecure_email_linking')
168
            && isset($fieldToClaimMap['Email'])
169
        ) {
170
            // If there is no member found via GUID and we allow linking via email, search by email
171
            $member = Member::get()->filter('Email', $attributes[$fieldToClaimMap['Email']])->limit(1)->first();
172
173
            if (!($member && $member->exists())) {
174
                $member = new Member();
175
            }
176
177
            $member->GUID = $guid;
178
        } elseif (!($member && $member->exists())) {
179
            // If the member doesn't exist and we don't allow linking via email, then create a new member
180
            $member = new Member();
181
            $member->GUID = $guid;
182
        }
183
184
        foreach ($member->config()->claims_field_mappings as $claim => $field) {
185
            if (!isset($attributes[$claim][0])) {
186
                $this->getLogger()->warning(
187
                    sprintf(
188
                        'Claim rule \'%s\' configured in SAMLMemberExtension.claims_field_mappings, ' .
189
                                'but wasn\'t passed through. Please check IdP claim rules.',
190
                        $claim
191
                    )
192
                );
193
194
                continue;
195
            }
196
197
            $member->$field = $attributes[$claim][0];
198
        }
199
200
        $member->SAMLSessionIndex = $auth->getSessionIndex();
201
202
        // This will trigger LDAP update through LDAPMemberExtension::memberLoggedIn, if the LDAP module is installed.
203
        // The LDAP update will also write the Member record a second time, but the member *must* be written before
204
        // IdentityStore->logIn() is called, otherwise the identity store throws an exception.
205
        // Both SAML and LDAP identify Members by the same GUID field.
206
        $member->write();
207
208
        /** @var IdentityStore $identityStore */
209
        $identityStore = Injector::inst()->get(IdentityStore::class);
210
        $identityStore->logIn($member, false, $this->getRequest());
211
212
        return $this->getRedirect();
213
    }
214
215
    /**
216
     * Generate this SP's metadata. This is needed for intialising the SP-IdP relationship.
217
     * IdP is instructed to call us back here to establish the relationship. IdP may also be configured
218
     * to hit this endpoint periodically during normal operation, to check the SP availability.
219
     */
220
    public function metadata()
221
    {
222
        try {
223
            /** @var Auth $auth */
224
            $auth = Injector::inst()->get(SAMLHelper::class)->getSAMLAuth();
225
            $settings = $auth->getSettings();
226
            $metadata = $settings->getSPMetadata();
227
            $errors = $settings->validateMetadata($metadata);
228
            if (empty($errors)) {
229
                header('Content-Type: text/xml');
230
                echo $metadata;
231
            } else {
232
                throw new Error(
233
                    'Invalid SP metadata: ' . implode(', ', $errors),
234
                    Error::METADATA_SP_INVALID
235
                );
236
            }
237
        } catch (Exception $e) {
238
            $this->getLogger()->error($e->getMessage());
239
            echo $e->getMessage();
240
        }
241
    }
242
243
    /**
244
     * @return HTTPResponse
245
     */
246
    protected function getRedirect()
247
    {
248
        // Absolute redirection URLs may cause spoofing
249
        if ($this->getRequest()->getSession()->get('BackURL')
250
            && Director::is_site_url($this->getRequest()->getSession()->get('BackURL'))) {
251
            return $this->redirect($this->getRequest()->getSession()->get('BackURL'));
252
        }
253
254
        // Spoofing attack, redirect to homepage instead of spoofing url
255
        if ($this->getRequest()->getSession()->get('BackURL')
256
            && !Director::is_site_url($this->getRequest()->getSession()->get('BackURL'))) {
257
            return $this->redirect(Director::absoluteBaseURL());
258
        }
259
260
        // If a default login dest has been set, redirect to that.
261
        if (Security::config()->default_login_dest) {
262
            return $this->redirect(Director::absoluteBaseURL() . Security::config()->default_login_dest);
263
        }
264
265
        // fallback to redirect back to home page
266
        return $this->redirect(Director::absoluteBaseURL());
267
    }
268
269
    /**
270
     * If processing reaches here, then the user is authenticated but potentially not valid. We first need to confirm
271
     * that they are not an attacker performing a SAML replay attack (capturing the raw traffic from a compromised
272
     * device and then re-submitting the same SAML response).
273
     *
274
     * To combat this, we store SAML response IDs for the amount of time they're valid for (plus a configurable offset
275
     * to account for potential time skew), and if the ID has been seen before we log an error message and return true
276
     * (which indicates that this specific request is a replay attack).
277
     *
278
     * If no replay attack is detected, then the SAML response is logged so that future requests can be blocked.
279
     *
280
     * @param Auth $auth The Auth object that includes the processed response
281
     * @param string $uniqueErrorId The error code to use when logging error messages for this given error
282
     * @return bool true if this response is a replay attack, false if it's the first time we've seen the ID
283
     */
284
    protected function checkForReplayAttack(Auth $auth, $uniqueErrorId = '')
285
    {
286
        $responseId = $auth->getLastMessageId();
287
        $expiry = $auth->getLastAssertionNotOnOrAfter(); // Note: Expiry will always be stored and returned in UTC
288
289
        // Search for any SAMLResponse objects where the response ID is the same and the expiry is within the range
290
        $count = SAMLResponse::get()->filter(['ResponseID' => $responseId])->count();
291
292
        if ($count > 0) {
293
            // Response found, therefore this is a replay attack - log the error and return false so the user is denied
294
            $this->getLogger()->error(sprintf(
295
                '[%s] SAML replay attack detected! Response ID "%s", expires "%s", client IP "%s"',
296
                $uniqueErrorId,
297
                $responseId,
298
                $expiry,
299
                $this->getRequest()->getIP()
300
            ));
301
302
            return true;
303
        } else {
304
            // No attack detected, log the SAML response
305
            $response = new SAMLResponse([
306
                'ResponseID' => $responseId,
307
                'Expiry' => $expiry
308
            ]);
309
310
            $response->write();
311
            return false;
312
        }
313
    }
314
315
    /**
316
     * Get a logger
317
     *
318
     * @return LoggerInterface
319
     */
320
    public function getLogger()
321
    {
322
        return Injector::inst()->get(LoggerInterface::class);
323
    }
324
325
    /**
326
     * Gets the login form
327
     *
328
     * @return SAMLLoginForm
329
     */
330
    public function getForm()
331
    {
332
        return Injector::inst()->get(SAMLLoginForm::class, true, [$this, SAMLAuthenticator::class, 'LoginForm']);
333
    }
334
}
335