Passed
Pull Request — master (#23)
by
unknown
02:38
created

RealMeService::getAuthData()   B

Complexity

Conditions 8
Paths 38

Size

Total Lines 58

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
nc 38
nop 0
dl 0
loc 58
rs 7.6719
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace SilverStripe\RealMe;
4
5
use DOMDocument;
6
use DOMNodeList;
7
use Exception;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, SilverStripe\RealMe\Exception. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
8
use InvalidArgumentException;
9
use OneLogin_Saml2_Auth;
10
use OneLogin_Saml2_Error;
11
use OneLogin_Saml2_Response;
12
use OneLogin_Saml2_Utils;
13
use Psr\Log\LoggerInterface;
14
use SilverStripe\Control\Controller;
15
use SilverStripe\Control\Director;
16
use SilverStripe\Control\HTTPRequest;
17
use SilverStripe\Control\Session;
18
use SilverStripe\Core\Config\Configurable;
19
use SilverStripe\Core\Convert;
20
use SilverStripe\Core\Environment;
21
use SilverStripe\Core\Injector\Injectable;
22
use SilverStripe\Core\Injector\Injector;
23
use SilverStripe\ORM\DataObject;
24
use SilverStripe\RealMe\Exception as RealMeException;
25
use SilverStripe\RealMe\Model\FederatedIdentity;
26
use SilverStripe\RealMe\Model\User;
27
use SilverStripe\Security\Member;
28
use SilverStripe\Security\Security;
29
use SilverStripe\View\TemplateGlobalProvider;
30
31
class RealMeService implements TemplateGlobalProvider
32
{
33
    use Configurable, Injectable;
34
35
    /**
36
     * Current RealMe supported environments.
37
     */
38
    const ENV_MTS = 'mts';
39
    const ENV_ITE = 'ite';
40
    const ENV_PROD = 'prod';
41
42
    /**
43
     * SAML binding types
44
     */
45
    const TYPE_LOGIN = 'login';
46
    const TYPE_ASSERT = 'assert';
47
48
    /**
49
     * the valid AuthN context values for each supported RealMe environment.
50
     */
51
    const AUTHN_LOW_STRENGTH = 'urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:LowStrength';
52
    const AUTHN_MOD_STRENTH = 'urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:ModStrength';
53
    const AUTHN_MOD_MOBILE_SMS =
54
        'urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:ModStrength::OTP:Mobile:SMS';
55
    const AUTHN_MOD_TOKEN_SID =
56
        'urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:ModStrength::OTP:Token:SID';
57
58
    /**
59
     * Realme SAML2 error status constants
60
     */
61
    const ERR_TIMEOUT                = 'urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:status:Timeout';
62
    const ERR_INTERNAL_ERROR         = 'urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:status:InternalError';
63
64
    /**
65
     * SAML2 Error constants used for business logic and switching error messages
66
     */
67
    const ERR_AUTHN_FAILED           = 'urn:oasis:names:tc:SAML:2.0:status:AuthnFailed';
68
    const ERR_UNKNOWN_PRINCIPAL      = 'urn:oasis:names:tc:SAML:2.0:status:UnknownPrincipal';
69
    const ERR_NO_AVAILABLE_IDP       = 'urn:oasis:names:tc:SAML:2.0:status:NoAvailableIDP';
70
    const ERR_NO_PASSIVE             = 'urn:oasis:names:tc:SAML:2.0:status:NoPassive';
71
    const ERR_NO_AUTHN_CONTEXT       = 'urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext';
72
    const ERR_REQUEST_UNSUPPORTED    = 'urn:oasis:names:tc:SAML:2.0:status:RequestUnsupported';
73
    const ERR_REQUEST_DENIED         = 'urn:oasis:names:tc:SAML:2.0:status:RequestDenied';
74
    const ERR_UNSUPPORTED_BINDING    = 'urn:oasis:names:tc:SAML:2.0:status:UnsupportedBinding';
75
76
    /**
77
     * @var bool true to sync RealMe data and create/update local {@link Member} objects upon successful authentication
78
     * @config
79
     */
80
    private static $sync_with_local_member_database = false;
81
82
    /**
83
     * @var User|null User data returned by RealMe. Provided by {@link self::ensureLogin()}.
84
     *
85
     * Data within this ArrayData is as follows:
86
     * - NameID:       ArrayData   Includes the UserFlt and associated formatting information
87
     * - UserFlt:      string      RealMe pseudonymous username / identity
88
     * - Attributes:   ArrayData   User attributes returned by RealMe
89
     * - Expire:       SS_Datetime The expiry date & time of this authentication session
90
     * - SessionIndex: string      Unique identifier used to identify a user with both IdP and SP for given user.
91
     */
92
    private static $user_data = null;
93
94
    /**
95
     * @config
96
     * @var string The RealMe environment to connect to and authenticate against. This should be set by Config, and
97
     * generally be different per SilverStripe environment (e.g. developer environments would generally use 'mts',
98
     * UAT/staging sites might use 'ite', and production sites would use 'prod'.
99
     *
100
     * Valid options:
101
     * - mts
102
     * - ite
103
     * - prod
104
     */
105
    private static $realme_env = 'mts';
106
107
    /**
108
     * @var array The RealMe environments that can be configured for use with this module.
109
     */
110
    private static $allowed_realme_environments = array(self::ENV_MTS, self::ENV_ITE, self::ENV_PROD);
111
112
    /**
113
     * @config
114
     * @var string The RealMe integration type to use when connecting to RealMe. After successful authentication:
115
     * - 'login' provides a unique FLT (Federated Login Token) back
116
     * - 'assert' provides a unique FIT (Federated Identity Token) and a {@link RealMeFederatedIdentity} object back
117
     */
118
    private static $integration_type = 'login';
119
120
    private static $allowed_realme_integration_types = array(self::TYPE_LOGIN, self::TYPE_ASSERT);
121
122
    /**
123
     * @config
124
     * @var array Stores the entity ID value for each supported RealMe environment. This needs to be setup prior to
125
     * running the `RealMeSetupTask` build task. For more information, see the module documentation. An entity ID takes
126
     * the form of a URL, e.g. https://www.agency.govt.nz/privacy-realm-name/application-name
127
     */
128
    private static $sp_entity_ids = array(
129
        self::ENV_MTS => null,
130
        self::ENV_ITE => null,
131
        self::ENV_PROD => null
132
    );
133
134
    /**
135
     * @config
136
     * @var array Stores the default identity provider (IdP) entity IDs. These can be customised if you're using an
137
     * intermediary IdP instead of connecting to RealMe directly.
138
     */
139
    private static $idp_entity_ids = array(
140
        self::ENV_MTS => array(
141
            self::TYPE_LOGIN  => 'https://mts.realme.govt.nz/saml2',
142
            self::TYPE_ASSERT => 'https://mts.realme.govt.nz/realmemts/realmeidp',
143
        ),
144
        self::ENV_ITE => array(
145
            self::TYPE_LOGIN  => 'https://www.ite.logon.realme.govt.nz/saml2',
146
            self::TYPE_ASSERT => 'https://www.ite.account.realme.govt.nz/saml2/assertion',
147
        ),
148
        self::ENV_PROD => array(
149
            self::TYPE_LOGIN  => 'https://www.logon.realme.govt.nz/saml2',
150
            self::TYPE_ASSERT => 'https://www.account.realme.govt.nz/saml2/assertion',
151
        )
152
    );
153
154
    private static $idp_sso_service_urls = array(
155
        self::ENV_MTS => array(
156
            self::TYPE_LOGIN  => 'https://mts.realme.govt.nz/logon-mts/mtsEntryPoint',
157
            self::TYPE_ASSERT => 'https://mts.realme.govt.nz/realme-mts/validate/realme-mts-idp.xhtml'
158
        ),
159
        self::ENV_ITE => array(
160
            self::TYPE_LOGIN  => 'https://www.ite.logon.realme.govt.nz/sso/logon/metaAlias/logon/logonidp',
161
            self::TYPE_ASSERT => 'https://www.ite.assert.realme.govt.nz/sso/SSORedirect/metaAlias/assertion/realmeidp'
162
        ),
163
        self::ENV_PROD => array(
164
            self::TYPE_LOGIN  => 'https://www.logon.realme.govt.nz/sso/logon/metaAlias/logon/logonidp',
165
            self::TYPE_ASSERT => 'https://www.assert.realme.govt.nz/sso/SSORedirect/metaAlias/assertion/realmeidp'
166
        )
167
    );
168
169
    /**
170
     * @var array A list of certificate filenames for different RealMe environments and integration types. These files
171
     * must be located in the directory specified by the REALME_CERT_DIR environment variable. These filenames are the
172
     * same as the files that can be found in the RealMe Shared Workspace, within the 'Integration Bundle' ZIP files for
173
     * the different environments (MTS, ITE and Production), so you just need to extract the specific certificate file
174
     * that you need and make sure it's in place on the server in the REALME_CERT_DIR.
175
     */
176
    private static $idp_x509_cert_filenames = array(
177
        self::ENV_MTS => array(
178
            self::TYPE_LOGIN  => 'mts_login_saml_idp.cer',
179
            self::TYPE_ASSERT => 'mts_assert_saml_idp.cer'
180
        ),
181
        self::ENV_ITE => array(
182
            self::TYPE_LOGIN  => 'ite.signing.logon.realme.govt.nz.cer',
183
            self::TYPE_ASSERT => 'ite.signing.account.realme.govt.nz.cer'
184
        ),
185
        self::ENV_PROD => array(
186
            self::TYPE_LOGIN  => 'signing.logon.realme.govt.nz.cer',
187
            self::TYPE_ASSERT => 'signing.account.realme.govt.nz.cer'
188
        )
189
    );
190
191
    /**
192
     * @config
193
     * @var array Stores the AuthN context values for each supported RealMe environment. This needs to be setup prior to
194
     * running the `RealMeSetupTask` build task. For more information, see the module documentation. An AuthN context
195
     * can be one of the following:
196
     *
197
     * Username and password only:
198
     * - urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:LowStrength
199
     *
200
     * Username, password, and any moderate strength second level of authenticator (RSA token, Google Auth, SMS)
201
     * - urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:ModStrength
202
     *
203
     * The following two are less often used, and shouldn't be used unless there's a specific need.
204
     *
205
     * Username, password, and only SMS 2FA token
206
     * - urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:ModStrength::OTP:Mobile:SMS
207
     *
208
     * Username, password, and only RSA 2FA token
209
     * - urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:ModStrength::OTP:Token:SID
210
     */
211
    private static $authn_contexts = array(
212
        self::ENV_MTS => null,
213
        self::ENV_ITE => null,
214
        self::ENV_PROD => null
215
    );
216
217
    /**
218
     * @config $allowed_authn_context_list
0 ignored issues
show
Documentation Bug introduced by
The doc comment $allowed_authn_context_list at position 0 could not be parsed: Unknown type name '$allowed_authn_context_list' at position 0 in $allowed_authn_context_list.
Loading history...
219
     * @var $allowed_authn_context_list array
220
     *
221
     * A list of the valid authn context values supported for realme.
222
     */
223
    private static $allowed_authn_context_list = array(
224
        self::AUTHN_LOW_STRENGTH,
225
        self::AUTHN_MOD_STRENTH,
226
        self::AUTHN_MOD_MOBILE_SMS,
227
        self::AUTHN_MOD_TOKEN_SID
228
    );
229
230
    /**
231
     * @config
232
     * @var array Domain names for metadata files. Used in @link RealMeSetupTask when outputting metadata XML
233
     */
234
    private static $metadata_assertion_service_domains = array(
235
        self::ENV_MTS => null,
236
        self::ENV_ITE => null,
237
        self::ENV_PROD => null
238
    );
239
240
    /**
241
     * @config
242
     * @var array A list of error messages to display if RealMe returns error statuses, instead of the default
243
     * translations (found in realme/lang/en.yml for example).
244
     */
245
    private static $realme_error_message_overrides = array(
246
        self::ERR_AUTHN_FAILED => null,
247
        self::ERR_TIMEOUT => null,
248
        self::ERR_INTERNAL_ERROR => null,
249
        self::ERR_NO_AVAILABLE_IDP => null,
250
        self::ERR_REQUEST_UNSUPPORTED => null,
251
        self::ERR_NO_PASSIVE => null,
252
        self::ERR_REQUEST_DENIED => null,
253
        self::ERR_UNSUPPORTED_BINDING => null,
254
        self::ERR_UNKNOWN_PRINCIPAL => null,
255
        self::ERR_NO_AUTHN_CONTEXT => null
256
    );
257
258
    /**
259
     * @config
260
     * @var string|null The organisation name to be used in metadata XML that is submitted to RealMe
261
     */
262
    private static $metadata_organisation_name = null;
263
264
    /**
265
     * @config
266
     * @var string|null The organisation display name to be used in metadata XML that is submitted to RealMe
267
     */
268
    private static $metadata_organisation_display_name = null;
269
270
    /**
271
     * @config
272
     * @var string|null The organisation URL to be used in metadata XML that is submitted to RealMe
273
     */
274
    private static $metadata_organisation_url = null;
275
276
    /**
277
     * @config
278
     * @var string|null The support contact's company name to be used in metadata XML that is submitted to RealMe
279
     */
280
    private static $metadata_contact_support_company = null;
281
282
    /**
283
     * @config
284
     * @var string|null The support contact's first name(s) to be used in metadata XML that is submitted to RealMe
285
     */
286
    private static $metadata_contact_support_firstnames = null;
287
288
    /**
289
     * @config
290
     * @var string|null The support contact's surname to be used in metadata XML that is submitted to RealMe
291
     */
292
    private static $metadata_contact_support_surname = null;
293
294
    /**
295
     * @var OneLogin_Saml2_Auth|null Set by {@link getAuth()}, which creates an instance of OneLogin_Saml2_Auth to check
296
     * authentication against
297
     */
298
    private $auth = null;
299
300
    /**
301
     * @var string|null The last error message during login enforcement
302
     */
303
    private $lastError = null;
304
305
    /**
306
     * @return array
307
     */
308
    public static function get_template_global_variables()
309
    {
310
        return array(
311
            'RealMeUser' => array(
312
                'method' => 'current_realme_user'
313
            )
314
        );
315
    }
316
317
    /**
318
     * Return the user data which was saved to session from the first RealMe
319
     * auth.
320
     * Note: Does not check authenticity or expiry of this data
321
     *
322
     * @param HTTPRequest $request
323
     * @return User
324
     */
325
    public static function user_data()
326
    {
327
        if (!is_null(static::$user_data)) {
0 ignored issues
show
Bug introduced by
Since $user_data is declared private, accessing it with static will lead to errors in possible sub-classes; you can either use self, or increase the visibility of $user_data to at least protected.
Loading history...
328
            return static::$user_data;
329
        }
330
331
        $request = Injector::inst()->get(HTTPRequest::class);
332
        $sessionData = $request->getSession()->get('RealMe.SessionData');
333
334
        // Exit point
335
        if (is_null($sessionData)) {
336
            return null;
337
        }
338
339
        // Unserialise stored data
340
        $user = unserialize($sessionData);
341
342
        if ($user == false || !$user instanceof User) {
343
            return null;
344
        }
345
346
        static::$user_data = $user;
347
        return static::$user_data;
348
    }
349
350
    public function getUserData()
351
    {
352
        return static::user_data();
353
    }
354
355
    /**
356
     * Calls available user data and checks for validity
357
     *
358
     * @return User
359
     */
360
    public static function current_realme_user()
361
    {
362
        $user = self::user_data();
363
        if ($user && !$user->isValid()) {
364
            return null;
365
        }
366
367
        return $user;
368
    }
369
370
    /**
371
     * Enforce login via RealMe. This can be used in controllers to force users to be authenticated via RealMe (not
372
     * necessarily logged in as a {@link Member}), in the form of:
373
     * <code>
374
     * Session::set('RealMeBackURL', '/path/to/the/controller/method');
375
     * if($service->enforceLogin()) {
376
     *     // User has a valid RealMe account, $service->getAuthData() will return you their details
377
     * } else {
378
     *     // Something went wrong processing their details, show an error
379
     * }
380
     * </code>
381
     *
382
     * In cases where people are *not* authenticated with RealMe, this method will redirect them directly to RealMe.
383
     *
384
     * However, generally you want this to be an explicit process, so you should look at instead using the standard
385
     * {@link RealMeAuthenticator}.
386
     *
387
     * A return value of bool false indicates that there was a failure during the authentication process (perhaps a
388
     * communication issue, or a failure to decode the response correctly. You should handle this like you would any
389
     * other unexpected authentication error. You can use {@link getLastError()} to see if a human-readable error
390
     * message exists for display to the user.
391
     *
392
     * @param HTTPRequest $request
393
     * @return bool|null true if the user is correctly authenticated, false if there was an error with login
394
     * @throws OneLogin_Saml2_Error
395
     */
396
    public function enforceLogin(HTTPRequest $request, $backUrl = null)
397
    {
398
        // First, check to see if we have an existing authenticated session
399
        if ($this->isAuthenticated()) {
400
            return true;
401
        }
402
403
        $session = $request->getSession();
404
405
        if ($backUrl) {
406
            $session->set('RealMeBackURL', $this->validSiteURL($backUrl));
407
        }
408
409
        // If not, attempt to retrieve authentication data from OneLogin (in case this is called during SAML assertion)
410
        try {
411
            if (!$session->get("RealMeErrorBackURL")) {
412
                $session->set("RealMeErrorBackURL", Controller::curr()->Link("Login"));
413
            }
414
415
            $auth = $this->getAuth();
416
            $auth->processResponse();
417
418
            // if there were any errors from the SAML request, process and translate them.
419
            $errors = $auth->getErrors();
420
            if (is_array($errors) && !empty($errors)) {
421
                $this->processSamlErrors($errors);
422
                return false;
423
            }
424
425
            $authData = $this->getAuthData();
426
427
            // If no data is found, then force login
428
            if (is_null($authData)) {
429
                throw new RealMeException('No SAML data, enforcing login', RealMeException::NOT_AUTHENTICATED);
430
            }
431
432
            // call a success method as we've successfully logged in (if it exists)
433
            Member::singleton()->extend('onRealMeLoginSuccess', $authData);
434
        } catch (Exception $e) {
435
            Member::singleton()->extend("onRealMeLoginFailure", $e);
436
437
            // No auth data or failed to decrypt, enforce login again
438
            $auth->login(Director::absoluteBaseURL());
439
            die;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
440
        }
441
442
        return $auth->isAuthenticated();
443
    }
444
445
    /**
446
     * If there was an error returned from the saml response, process the errors
447
     *
448
     * @param $errors
449
     */
450
    private function processSamlErrors(array $errors)
451
    {
452
        $translatedMessage = null;
453
454
        // The error message returned by onelogin/php-saml is the top-level error, but we want the actual error
455
        $request = Controller::curr()->getRequest();
456
        if ($request->isPOST() && $request->postVar("SAMLResponse")) {
457
            $response = new OneLogin_Saml2_Response($this->getAuth()->getSettings(), $request->postVar("SAMLResponse"));
458
            $internalError = OneLogin_Saml2_Utils::query(
459
                $response->document,
460
                "/samlp:Response/samlp:Status/samlp:StatusCode/samlp:StatusCode/@Value"
461
            );
462
463
            if ($internalError instanceof DOMNodeList && $internalError->length > 0) {
464
                $internalErrorCode = $internalError->item(0)->textContent;
465
                $translatedMessage = $this->findErrorMessageForCode($internalErrorCode);
466
            }
467
        }
468
469
        // If we found a message to display, then let's redirect to the form and display it
470
        if ($translatedMessage) {
471
            $this->lastError = $translatedMessage;
472
        }
473
474
        Injector::inst()->get(LoggerInterface::class)->info(sprintf(
475
            'onelogin/php-saml error messages: %s (%s)',
476
            join(', ', $errors),
477
            $this->getAuth()->getLastErrorReason()
478
        ));
479
    }
480
481
    /**
482
     * Checks data stored in Session to see if the user is authenticated.
483
     * @return bool true if the user is authenticated via RealMe and we can trust ->getUserData()
484
     */
485
    public function isAuthenticated()
486
    {
487
        $user = $this->getUserData();
488
        return $user instanceof User && $user->isAuthenticated();
489
    }
490
491
    /**
492
     * Returns a {@link RealMeUser} object if one can be built from the RealMe session data.
493
     *
494
     * @throws OneLogin_Saml2_Error Passes on the SAML error if it's not indicating a lack of SAML response data
495
     * @throws RealMeException If identity information exists but couldn't be decoded, or doesn't exist
496
     * @return User|null
497
     */
498
    public function getAuthData()
499
    {
500
        // returns null if the current auth is invalid or timed out.
501
        try {
502
            // Process response and capture details
503
            $auth = $this->getAuth();
504
505
            if (!$auth->isAuthenticated()) {
506
                throw new RealMeException(
507
                    'OneLogin SAML library did not successfully authenticate, but did not return a specific error',
508
                    RealMeException::NOT_AUTHENTICATED
509
                );
510
            }
511
512
            $spNameId = $auth->getNameId();
513
            if (!is_string($spNameId)) {
0 ignored issues
show
introduced by
The condition is_string($spNameId) is always true.
Loading history...
514
                throw new RealMeException('Invalid/Missing NameID in SAML response', RealMeException::MISSING_NAMEID);
515
            }
516
517
            $sessionIndex = $auth->getSessionIndex();
518
            if (!is_string($sessionIndex)) {
0 ignored issues
show
introduced by
The condition is_string($sessionIndex) is always true.
Loading history...
519
                throw new RealMeException(
520
                    'Invalid/Missing SessionIndex value in SAML response',
521
                    RealMeException::MISSING_SESSION_INDEX
522
                );
523
            }
524
525
            $attributes = $auth->getAttributes();
526
            if (!is_array($attributes)) {
0 ignored issues
show
introduced by
The condition is_array($attributes) is always true.
Loading history...
527
                throw new RealMeException(
528
                    'Invalid/Missing attributes array in SAML response',
529
                    RealMeException::MISSING_ATTRIBUTES
530
                );
531
            }
532
533
            $federatedIdentity = $this->retrieveFederatedIdentity($auth);
534
535
            // We will have either a FLT or FIT, depending on integration type
536
            if ($this->config()->integration_type == self::TYPE_ASSERT) {
537
                $userTag = $this->retrieveFederatedIdentityTag($auth);
538
            } else {
539
                $userTag = $this->retrieveFederatedLogonTag($auth);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $userTag is correct as $this->retrieveFederatedLogonTag($auth) targeting SilverStripe\RealMe\Real...ieveFederatedLogonTag() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
540
            }
541
542
            return User::create([
543
                'SPNameID' => $spNameId,
544
                'UserFederatedTag' => $userTag,
545
                'SessionIndex' => $sessionIndex,
546
                'Attributes' => $attributes,
547
                'FederatedIdentity' => $federatedIdentity,
548
            ]);
549
        } catch (OneLogin_Saml2_Error $e) {
550
            // If the Exception code indicates there wasn't a response, we ignore it as it simply means the visitor
551
            // isn't authenticated yet. Otherwise, we re-throw the Exception
552
            if ($e->getCode() === OneLogin_Saml2_Error::SAML_RESPONSE_NOT_FOUND) {
553
                return null;
554
            } else {
555
                throw $e;
556
            }
557
        }
558
    }
559
560
    /**
561
     * Clear the RealMe credentials from Session, called during Security->logout() overrides
562
     *
563
     * @param HTTPRequest $request
564
     * @return void
565
     */
566
    public function clearLogin(HTTPRequest $request)
567
    {
568
        $this->config()->__set('user_data', null);
569
        $session = $request->getSession();
570
571
        $session->set("RealMeBackURL", null);
572
        $session->set("RealMeErrorBackURL", null);
573
        $session->set("RealMe.SessionData", null);
574
        $session->set("RealMe.OriginalResponse", null);
575
        $session->set("RealMe.LastErrorMessage", null);
576
    }
577
578
    public function getLastError()
579
    {
580
        return $this->lastError;
581
    }
582
583
    /**
584
     * @return string A BackURL as specified originally when accessing /Security/login, for use after authentication
585
     */
586
    public function getBackURL(HTTPRequest $request)
587
    {
588
        $url = null;
589
        $session = $request->getSession();
590
591
        if ($session->get('RealMeBackURL')) {
592
            $url = $session->get('RealMeBackURL');
593
            $session->clear('RealMeBackURL'); // Ensure we don't redirect back to the same error twice
594
        }
595
596
        return $this->validSiteURL($url);
597
    }
598
599
    public function getErrorBackURL(HTTPRequest $request)
600
    {
601
        $url = null;
602
        $session = $request->getSession();
603
604
        if ($session->get('RealMeErrorBackURL')) {
605
            $url = $session->get('RealMeErrorBackURL');
606
            $session->clear('RealMeErrorBackURL'); // Ensure we don't redirect back to the same error twice
607
        }
608
609
        return $this->validSiteURL($url);
610
    }
611
612
    private function validSiteURL($url = null)
613
    {
614
        if (isset($url) && Director::is_site_url($url)) {
615
            $url = Director::absoluteURL($url);
616
        } else {
617
            // Spoofing attack or no back URL set, redirect to homepage instead of spoofing url
618
            $url = Director::absoluteBaseURL();
619
        }
620
621
        return $url;
622
    }
623
624
    /**
625
     * @param String $subdir A sub-directory where certificates may be stored for
626
     * a specific case
627
     * @return string|null Either the directory where certificates are stored,
628
     * or null if undefined
629
     */
630
    public function getCertDir($subdir = null)
631
    {
632
633
        // Trim prepended seprator to avoid absolute path
634
        $path = ltrim(ltrim($subdir, '/'), '\\');
635
636
        if ($certDir = Environment::getEnv('REALME_CERT_DIR')) {
637
            $path = $certDir . '/' . $path; // Duplicate slashes will be handled by realpath()
638
        }
639
640
        return realpath($path);
641
    }
642
643
    /**
644
     * Returns the appropriate AuthN Context, given the environment passed in. The AuthNContext may be different per
645
     * environment, and should be one of the strings as defined in the static {@link self::$authn_contexts} at the top
646
     * of this class.
647
     *
648
     * @param string $env The environment to return the AuthNContext for. Must be one of the RealMe environment names
649
     * @return string|null Returns the AuthNContext for the given $env, or null if no context exists
650
     */
651
    public function getAuthnContextForEnvironment($env)
652
    {
653
        return $this->getConfigurationVarByEnv('authn_contexts', $env);
654
    }
655
656
    /**
657
     * Returns the full path to the SAML signing certificate file, used by SimpleSAMLphp to sign all messages sent to
658
     * RealMe.
659
     *
660
     * @return string|null Either the full path to the SAML signing certificate file, or null if it doesn't exist
661
     */
662
    public function getSigningCertPath()
663
    {
664
        return $this->getCertPath('SIGNING');
665
    }
666
667
    public function getIdPCertPath()
668
    {
669
        $cfg = $this->config();
670
        $name = $this->getConfigurationVarByEnv('idp_x509_cert_filenames', $cfg->realme_env, $cfg->integration_type);
671
672
        return $this->getCertDir($name);
673
    }
674
675
    public function getSPCertContent($contentType = 'certificate')
676
    {
677
        return $this->getCertificateContents($this->getSigningCertPath(), $contentType);
678
    }
679
680
    public function getIdPCertContent()
681
    {
682
        return $this->getCertificateContents($this->getIdPCertPath());
683
    }
684
685
    /**
686
     * Returns the content of the SAML signing certificate. This is used by getAuth() and by RealMeSetupTask to produce
687
     * metadata XML files.
688
     *
689
     * @param string $certPath The filesystem path to where the certificate is stored on the filesystem
690
     * @param string $contentType Either 'certificate' or 'key', depending on which part of the file to return
691
     * @return string|null The content of the signing certificate
692
     */
693
    public function getCertificateContents($certPath, $contentType = 'certificate')
694
    {
695
        $text = null;
696
697
        if (!is_null($certPath)) {
0 ignored issues
show
introduced by
The condition is_null($certPath) is always false.
Loading history...
698
            $certificateContents = file_get_contents($certPath);
699
700
            // If the file does not contain any header information and the content type is certificate, just return it
701
            if ($contentType == 'certificate' && !preg_match('/-----BEGIN/', $certificateContents)) {
702
                $text = $certificateContents;
703
            } else {
704
                // Otherwise, inspect the file and match based on the full contents
705
                if ($contentType == 'certificate') {
706
                    $pattern = '/-----BEGIN CERTIFICATE-----[\r\n]*([^-]*)[\r\n]*-----END CERTIFICATE-----/';
707
                } elseif ($contentType == 'key') {
708
                    $pattern = '/-----BEGIN [A-Z ]*PRIVATE KEY-----\n([^-]*)\n-----END [A-Z ]*PRIVATE KEY-----/';
709
                } else {
710
                    throw new InvalidArgumentException('Argument contentType must be either "certificate" or "key"');
711
                }
712
713
                // This is a PEM key, and we need to extract just the certificate, stripping out the private key etc.
714
                // So we search for everything between '-----BEGIN CERTIFICATE-----' and '-----END CERTIFICATE-----'
715
                preg_match(
716
                    $pattern,
717
                    $certificateContents,
718
                    $matches
719
                );
720
721
                if (isset($matches) && is_array($matches) && isset($matches[1])) {
722
                    $text = trim($matches[1]);
723
                }
724
            }
725
        }
726
727
        return $text;
728
    }
729
730
    /**
731
     * @param string $env The environment to return the entity ID for. Must be one of the RealMe environment names
732
     * @return string|null Either the assertion consumer service location, or null if information doesn't exist
733
     */
734
    public function getAssertionConsumerServiceUrlForEnvironment($env)
735
    {
736
        if (in_array($env, $this->getAllowedRealMeEnvironments()) === false) {
737
            return null;
738
        }
739
740
        $domain = $this->getMetadataAssertionServiceDomainForEnvironment($env);
741
        if (filter_var($domain, FILTER_VALIDATE_URL) === false) {
742
            return null;
743
        }
744
745
        // Returns https://domain.govt.nz/Security/login/RealMe/acs
746
        return Controller::join_links($domain, Security::config()->get('login_url'), 'RealMe/acs');
747
    }
748
749
    /**
750
     * @return string|null The organisation name to be used in metadata XML output, or null if none exists
751
     */
752
    public function getMetadataOrganisationName()
753
    {
754
        $orgName = $this->config()->metadata_organisation_name;
755
        return (strlen($orgName) > 0) ? $orgName : null;
756
    }
757
758
    /**
759
     * @return string|null The organisation display name to be used in metadata XML output, or null if none exists
760
     */
761
    public function getMetadataOrganisationDisplayName()
762
    {
763
        $displayName = $this->config()->metadata_organisation_display_name;
764
        return (strlen($displayName) > 0) ? $displayName : null;
765
    }
766
767
    /**
768
     * @return string|null The organisation website URL to be used in metadata XML output, or null if none exists
769
     */
770
    public function getMetadataOrganisationUrl()
771
    {
772
        $url = $this->config()->metadata_organisation_url;
773
        return (strlen($url) > 0) ? $url: null;
774
    }
775
776
    /**
777
     * @return string[] The support contact details to be used in metadata XML output, with null values if they don't
778
     *                  exist
779
     */
780
    public function getMetadataContactSupport()
781
    {
782
        $company = $this->config()->metadata_contact_support_company;
783
        $firstNames = $this->config()->metadata_contact_support_firstnames;
784
        $surname = $this->config()->metadata_contact_support_surname;
785
786
        return array(
787
            'company' => (strlen($company) > 0) ? $company : null,
788
            'firstNames' => (strlen($firstNames) > 0) ? $firstNames : null,
789
            'surname' => (strlen($surname) > 0) ? $surname : null
790
        );
791
    }
792
793
    /**
794
     * The list of RealMe environments that can be used. By default, we allow mts, ite and production.
795
     * @return array
796
     */
797
    public function getAllowedRealMeEnvironments()
798
    {
799
        return $this->config()->allowed_realme_environments;
800
    }
801
802
    /**
803
     * The list of valid realme AuthNContexts
804
     * @return array
805
     */
806
    public function getAllowedAuthNContextList()
807
    {
808
        return $this->config()->allowed_authn_context_list;
809
    }
810
811
    /**
812
     * Returns the appropriate entity ID for RealMe, given the environment passed in. The entity ID may be different per
813
     * environment, and should be a full URL, including privacy realm and application name. For example, this may be:
814
     * https://www.agency.govt.nz/privacy-realm-name/application-name
815
     *
816
     * @return string|null Returns the entity ID for the current environment, or null if no entity ID exists
817
     */
818
    public function getSPEntityID()
819
    {
820
        return $this->getConfigurationVarByEnv('sp_entity_ids', $this->config()->realme_env);
821
    }
822
823
    private function getIdPEntityID()
824
    {
825
        $cfg = $this->config();
826
        return $this->getConfigurationVarByEnv('idp_entity_ids', $cfg->realme_env, $cfg->integration_type);
827
    }
828
829
    private function getSingleSignOnServiceURL()
830
    {
831
        $cfg = $this->config();
832
        return $this->getConfigurationVarByEnv('idp_sso_service_urls', $cfg->realme_env, $cfg->integration_type);
833
    }
834
835
    private function getRequestedAuthnContext()
836
    {
837
        return $this->getConfigurationVarByEnv('authn_contexts', $this->config()->realme_env);
838
    }
839
840
    /**
841
     * Returns the internal {@link OneLogin_Saml2_Auth} object against which visitors are authenticated.
842
     *
843
     * @return OneLogin_Saml2_Auth
844
     */
845
    public function getAuth(HTTPRequest $request = null)
846
    {
847
        if (isset($this->auth)) {
848
            return $this->auth;
849
        }
850
851
        if (!$request) {
852
            $request = Injector::inst()->get(HTTPRequest::class);
853
        }
854
855
        // Ensure onelogin is using the correct host, protocol and port incase a proxy is involved
856
        OneLogin_Saml2_Utils::setSelfHost($request->getHeader('Host'));
857
        OneLogin_Saml2_Utils::setSelfProtocol($request->getScheme());
858
859
        $port = null;
860
        if (isset($_SERVER['HTTP_X_FORWARDED_PORT'])) {
861
            $port = $_SERVER['HTTP_X_FORWARDED_PORT'];
862
        } elseif (isset($_SERVER['SERVER_PORT'])) {
863
            $port = $_SERVER['SERVER_PORT'];
864
        }
865
866
        if ($port) {
867
            OneLogin_Saml2_Utils::setSelfPort($port);
868
        }
869
870
        $settings = [
871
            'strict' => true,
872
            'debug' => false,
873
874
            // Service Provider (this installation) configuration
875
            'sp' => [
876
                'entityId' => $this->getSPEntityID(),
877
                'x509cert' => $this->getSPCertContent('certificate'),
878
                'privateKey' => $this->getSPCertContent('key'),
879
880
                // According to RealMe messaging spec, must always be transient for assert; is irrelevant for login
881
                'NameIDFormat' => $this->getNameIdFormat(),
882
883
                'assertionConsumerService' => [
884
                    'url' => $this->getAssertionConsumerServiceUrlForEnvironment($this->config()->realme_env),
885
                    'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' // Always POST, not artifact binding
886
                ]
887
            ],
888
889
            // RealMe Identity Provider configuration
890
            'idp' => [
891
                'entityId' => $this->getIdPEntityID(),
892
                'x509cert' => $this->getIdPCertContent(),
893
894
                'singleSignOnService' => [
895
                    'url' => $this->getSingleSignOnServiceURL(),
896
                    'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
897
                ]
898
            ],
899
900
            'security' => [
901
                'signatureAlgorithm' => 'http://www.w3.org/2000/09/xmldsig#rsa-sha1',
902
                'authnRequestsSigned' => true,
903
                'wantAssertionsEncrypted' => true,
904
                'wantAssertionsSigned' => true,
905
906
                'requestedAuthnContext' => [
907
                    $this->getRequestedAuthnContext()
908
                ]
909
            ]
910
        ];
911
912
        $this->auth = new OneLogin_Saml2_Auth($settings);
913
        return $this->auth;
914
    }
915
916
    /**
917
     * @return string the required NameIDFormat to be included in metadata XML, based on the requested integration type
918
     */
919
    public function getNameIdFormat()
920
    {
921
        switch ($this->config()->integration_type) {
922
            case self::TYPE_ASSERT:
923
                return 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient';
924
                break;
0 ignored issues
show
Unused Code introduced by
break is not strictly necessary here and could be removed.

The break statement is not necessary if it is preceded for example by a return statement:

switch ($x) {
    case 1:
        return 'foo';
        break; // This break is not necessary and can be left off.
}

If you would like to keep this construct to be consistent with other case statements, you can safely mark this issue as a false-positive.

Loading history...
925
926
            case self::TYPE_LOGIN:
927
            default:
928
                return 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
929
                break;
930
        }
931
    }
932
933
    /**
934
     * @param string $cfgName The static configuration value to get. This should be an array
935
     * @param string $env The environment to return the value for. Must be one of the RealMe environment names
936
     * @param string $integrationType The integration type (login or assert), if necessary, to determine return var
937
     * @throws InvalidArgumentException If the cfgVar doesn't exist, or is malformed
938
     * @return string|null Returns the value as defined in $cfgName for the given environment, or null if none exist
939
     */
940
    private function getConfigurationVarByEnv($cfgName, $env, $integrationType = null)
941
    {
942
        $value = null;
943
944
        if (in_array($env, $this->getAllowedRealMeEnvironments())) {
945
            $values = $this->config()->$cfgName;
946
947
            if (is_array($values) && isset($values[$env])) {
948
                $value = $values[$env];
949
            }
950
        }
951
952
        // If $integrationType is specified, then $value should be an array, with the array key being the integration
953
        // type and array value being the returned variable
954
        if (!is_null($integrationType) && is_array($value) && isset($value[$integrationType])) {
955
            $value = $value[$integrationType];
956
        } elseif (!is_null($integrationType)) {
957
            // Otherwise, we are expecting an integration type, but the value is not specified that way, error out
958
            throw new InvalidArgumentException(
959
                sprintf(
960
                    'Config value %s[%s][%s] not well formed (cfg var not an array)',
961
                    $cfgName,
962
                    $env,
963
                    $integrationType
964
                )
965
            );
966
        }
967
968
        if (is_null($value)) {
969
            throw new InvalidArgumentException(sprintf('Config value %s[%s] not set', $cfgName, $env));
970
        }
971
972
        return $value;
973
    }
974
975
    /**
976
     * @param string $certName The certificate name, either 'SIGNING' or 'MUTUAL'
977
     * @return string|null Either the full path to the certificate file, or null if it doesn't exist
978
     * @see self::getSigningCertPath()
979
     */
980
    private function getCertPath($certName)
981
    {
982
        $certPath = null;
983
984
        if (in_array($certName, array('SIGNING', 'MUTUAL'))) {
985
            $constName = sprintf('REALME_%s_CERT_FILENAME', strtoupper($certName));
986
            if ($filename = Environment::getEnv($constName)) {
987
                $certPath = $this->getCertDir($filename);
988
            }
989
        }
990
991
        // Ensure the file exists, if it doesn't then set it to null
992
        if (!is_null($certPath) && !file_exists($certPath)) {
993
            $certPath = null;
994
        }
995
996
        return $certPath;
997
    }
998
999
    /**
1000
     * @param string $env The environment to return the domain name for. Must be one of the RealMe environment names
1001
     * @return string|null Either the FQDN (e.g. https://www.realme-demo.govt.nz/) or null if none is specified
1002
     */
1003
    private function getMetadataAssertionServiceDomainForEnvironment($env)
1004
    {
1005
        return $this->getConfigurationVarByEnv('metadata_assertion_service_domains', $env);
1006
    }
1007
1008
    /**
1009
     * @param OneLogin_Saml2_Auth $auth
1010
     * @return string|null null if there's no FLT, or a string if there is one
1011
     */
1012
    private function retrieveFederatedLogonTag(OneLogin_Saml2_Auth $auth)
0 ignored issues
show
Unused Code introduced by
The parameter $auth is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

1012
    private function retrieveFederatedLogonTag(/** @scrutinizer ignore-unused */ OneLogin_Saml2_Auth $auth)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
1013
    {
1014
        return null; // @todo
1015
    }
1016
1017
    /**
1018
     * @param OneLogin_Saml2_Auth $auth
1019
     * @return string|null null if there's not FIT, or a string if there is one
1020
     */
1021
    private function retrieveFederatedIdentityTag(OneLogin_Saml2_Auth $auth)
1022
    {
1023
        $fit = null;
1024
        $attributes = $auth->getAttributes();
1025
1026
        if (isset($attributes['urn:nzl:govt:ict:stds:authn:attribute:igovt:IVS:FIT'])) {
1027
            $fit = $attributes['urn:nzl:govt:ict:stds:authn:attribute:igovt:IVS:FIT'][0];
1028
        }
1029
1030
        return $fit;
1031
    }
1032
1033
    /**
1034
     * @param OneLogin_Saml2_Auth $auth
1035
     * @return FederatedIdentity|null
1036
     * @throws RealMeException
1037
     */
1038
    private function retrieveFederatedIdentity(OneLogin_Saml2_Auth $auth)
1039
    {
1040
        $federatedIdentity = null;
1041
        $attributes = $auth->getAttributes();
1042
        $nameId = $auth->getNameId();
1043
1044
        // If identity information exists, retrieve the FIT (Federated Identity Tag) and identity data
1045
        if (isset($attributes['urn:nzl:govt:ict:stds:authn:safeb64:attribute:igovt:IVS:Assertion:Identity'])) {
1046
            // Identity information is encoded using 'Base 64 Encoding with URL and Filename Safe Alphabet'
1047
            // For more info, review RFC3548, section 4 (https://tools.ietf.org/html/rfc3548#page-6)
1048
            // Note: This is different to PHP's standard base64_decode() function, therefore we need to swap chars
1049
            // to match PHP's expectations:
1050
            // char 62 (-) becomes +
1051
            // char 63 (_) becomes /
1052
1053
            $identity = $attributes['urn:nzl:govt:ict:stds:authn:safeb64:attribute:igovt:IVS:Assertion:Identity'];
1054
1055
            if (!is_array($identity) || !isset($identity[0])) {
1056
                throw new RealMeException(
1057
                    'Invalid identity response received from RealMe',
1058
                    RealMeException::INVALID_IDENTITY_VALUE
1059
                );
1060
            }
1061
1062
            // Switch from filename-safe alphabet base64 encoding to standard base64 encoding
1063
            $identity = strtr($identity[0], '-_', '+/');
1064
            $identity = base64_decode($identity, true);
1065
1066
            if (is_bool($identity) && !$identity) {
0 ignored issues
show
introduced by
The condition is_bool($identity) is always false.
Loading history...
1067
                // Strict base64_decode fails, either the identity didn't exist or was mangled during transmission
1068
                throw new RealMeException(
1069
                    'Failed to parse safe base64 encoded identity',
1070
                    RealMeException::FAILED_PARSING_IDENTITY
1071
                );
1072
            }
1073
1074
            $identityDoc = new DOMDocument();
1075
            if ($identityDoc->loadXML($identity)) {
1076
                $federatedIdentity = new FederatedIdentity($identityDoc, $nameId);
1077
            }
1078
        }
1079
1080
        return $federatedIdentity;
1081
    }
1082
1083
    /**
1084
     * Finds a human-readable error message based on the error code provided in the RealMe SAML response
1085
     *
1086
     * @return string|null The human-readable error message, or null if one can't be found
1087
     */
1088
    private function findErrorMessageForCode($errorCode)
1089
    {
1090
        $message = null;
1091
        $messageOverrides = $this->config()->realme_error_message_overrides;
1092
1093
        switch ($errorCode) {
1094
            case self::ERR_AUTHN_FAILED:
1095
                $message = _t('RealMeService.ERROR_AUTHNFAILED', 'You have chosen to leave RealMe.');
1096
                break;
1097
1098
            case self::ERR_TIMEOUT:
1099
                $message = _t('RealMeService.ERROR_TIMEOUT');
1100
                break;
1101
1102
            case self::ERR_INTERNAL_ERROR:
1103
                $message = _t('RealMeService.ERROR_INTERNAL');
1104
                break;
1105
1106
            case self::ERR_NO_AVAILABLE_IDP:
1107
                $message = _t('RealMeService.ERROR_NOAVAILABLEIDP');
1108
                break;
1109
1110
            case self::ERR_REQUEST_UNSUPPORTED:
1111
                $message = _t('RealMeService.ERROR_REQUESTUNSUPPORTED');
1112
                break;
1113
1114
            case self::ERR_NO_PASSIVE:
1115
                $message = _t('RealMeService.ERROR_NOPASSIVE');
1116
                break;
1117
1118
            case self::ERR_REQUEST_DENIED:
1119
                $message = _t('RealMeService.ERROR_REQUESTDENIED');
1120
                break;
1121
1122
            case self::ERR_UNSUPPORTED_BINDING:
1123
                $message = _t('RealMeService.ERROR_UNSUPPORTEDBINDING');
1124
                break;
1125
1126
            case self::ERR_UNKNOWN_PRINCIPAL:
1127
                $message = _t('RealMeService.ERROR_UNKNOWNPRINCIPAL');
1128
                break;
1129
1130
            case self::ERR_NO_AUTHN_CONTEXT:
1131
                $message = _t('RealMeService.ERROR_NOAUTHNCONTEXT');
1132
                break;
1133
1134
            default:
1135
                $message = _t('RealMeService.ERROR_GENERAL');
1136
                break;
1137
        }
1138
1139
        // Allow message overrides if they exist
1140
        if (array_key_exists($errorCode, $messageOverrides) && !is_null($messageOverrides[$errorCode])) {
1141
            $message = $messageOverrides[$errorCode];
1142
        }
1143
1144
        return $message;
1145
    }
1146
}
1147