Passed
Push — master ( 9b983a...31893a )
by
unknown
02:23
created

src/RealMeService.php (1 issue)

Labels
Severity
1
<?php
2
3
namespace SilverStripe\RealMe;
4
5
use DOMDocument;
6
use DOMNodeList;
7
use Exception;
0 ignored issues
show
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\Core\Config\Configurable;
18
use SilverStripe\Core\Environment;
19
use SilverStripe\Core\Injector\Injectable;
20
use SilverStripe\Core\Injector\Injector;
21
use SilverStripe\RealMe\Exception as RealMeException;
22
use SilverStripe\RealMe\Model\FederatedIdentity;
23
use SilverStripe\RealMe\Model\User;
24
use SilverStripe\Security\Member;
25
use SilverStripe\Security\Security;
26
use SilverStripe\View\TemplateGlobalProvider;
27
28
class RealMeService implements TemplateGlobalProvider
29
{
30
    use Configurable, Injectable;
31
32
    /**
33
     * Current RealMe supported environments.
34
     */
35
    const ENV_MTS = 'mts';
36
    const ENV_ITE = 'ite';
37
    const ENV_PROD = 'prod';
38
39
    /**
40
     * SAML binding types
41
     */
42
    const TYPE_LOGIN = 'login';
43
    const TYPE_ASSERT = 'assert';
44
45
    /**
46
     * the valid AuthN context values for each supported RealMe environment.
47
     */
48
    const AUTHN_LOW_STRENGTH = 'urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:LowStrength';
49
    const AUTHN_MOD_STRENTH = 'urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:ModStrength';
50
    const AUTHN_MOD_MOBILE_SMS =
51
        'urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:ModStrength::OTP:Mobile:SMS';
52
    const AUTHN_MOD_TOKEN_SID =
53
        'urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:ModStrength::OTP:Token:SID';
54
55
    /**
56
     * Realme SAML2 error status constants
57
     */
58
    const ERR_TIMEOUT                = 'urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:status:Timeout';
59
    const ERR_INTERNAL_ERROR         = 'urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:status:InternalError';
60
61
    /**
62
     * SAML2 Error constants used for business logic and switching error messages
63
     */
64
    const ERR_AUTHN_FAILED           = 'urn:oasis:names:tc:SAML:2.0:status:AuthnFailed';
65
    const ERR_UNKNOWN_PRINCIPAL      = 'urn:oasis:names:tc:SAML:2.0:status:UnknownPrincipal';
66
    const ERR_NO_AVAILABLE_IDP       = 'urn:oasis:names:tc:SAML:2.0:status:NoAvailableIDP';
67
    const ERR_NO_PASSIVE             = 'urn:oasis:names:tc:SAML:2.0:status:NoPassive';
68
    const ERR_NO_AUTHN_CONTEXT       = 'urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext';
69
    const ERR_REQUEST_UNSUPPORTED    = 'urn:oasis:names:tc:SAML:2.0:status:RequestUnsupported';
70
    const ERR_REQUEST_DENIED         = 'urn:oasis:names:tc:SAML:2.0:status:RequestDenied';
71
    const ERR_UNSUPPORTED_BINDING    = 'urn:oasis:names:tc:SAML:2.0:status:UnsupportedBinding';
72
73
    /**
74
     * @var bool true to sync RealMe data and create/update local {@link Member} objects upon successful authentication
75
     * @config
76
     */
77
    private static $sync_with_local_member_database = false;
78
79
    /**
80
     * @var User|null User data returned by RealMe. Provided by {@link self::ensureLogin()}.
81
     *
82
     * Data within this ArrayData is as follows:
83
     * - NameID:       ArrayData   Includes the UserFlt and associated formatting information
84
     * - UserFlt:      string      RealMe pseudonymous username / identity
85
     * - Attributes:   ArrayData   User attributes returned by RealMe
86
     * - Expire:       SS_Datetime The expiry date & time of this authentication session
87
     * - SessionIndex: string      Unique identifier used to identify a user with both IdP and SP for given user.
88
     */
89
    private static $user_data = null;
90
91
    /**
92
     * @config
93
     * @var string The RealMe environment to connect to and authenticate against. This should be set by Config, and
94
     * generally be different per SilverStripe environment (e.g. developer environments would generally use 'mts',
95
     * UAT/staging sites might use 'ite', and production sites would use 'prod'.
96
     *
97
     * Valid options:
98
     * - mts
99
     * - ite
100
     * - prod
101
     */
102
    private static $realme_env = 'mts';
103
104
    /**
105
     * @var array The RealMe environments that can be configured for use with this module.
106
     */
107
    private static $allowed_realme_environments = array(self::ENV_MTS, self::ENV_ITE, self::ENV_PROD);
108
109
    /**
110
     * @config
111
     * @var string The RealMe integration type to use when connecting to RealMe. After successful authentication:
112
     * - 'login' provides a unique FLT (Federated Login Token) back
113
     * - 'assert' provides a unique FIT (Federated Identity Token) and a {@link RealMeFederatedIdentity} object back
114
     */
115
    private static $integration_type = 'login';
116
117
    private static $allowed_realme_integration_types = array(self::TYPE_LOGIN, self::TYPE_ASSERT);
118
119
    /**
120
     * @config
121
     * @var array Stores the entity ID value for each supported RealMe environment. This needs to be setup prior to
122
     * running the `RealMeSetupTask` build task. For more information, see the module documentation. An entity ID takes
123
     * the form of a URL, e.g. https://www.agency.govt.nz/privacy-realm-name/application-name
124
     */
125
    private static $sp_entity_ids = array(
126
        self::ENV_MTS => null,
127
        self::ENV_ITE => null,
128
        self::ENV_PROD => null
129
    );
130
131
    /**
132
     * @config
133
     * @var array Stores the default identity provider (IdP) entity IDs. These can be customised if you're using an
134
     * intermediary IdP instead of connecting to RealMe directly.
135
     */
136
    private static $idp_entity_ids = array(
137
        self::ENV_MTS => array(
138
            self::TYPE_LOGIN  => 'https://mts.realme.govt.nz/saml2',
139
            self::TYPE_ASSERT => 'https://mts.realme.govt.nz/realmemts/realmeidp',
140
        ),
141
        self::ENV_ITE => array(
142
            self::TYPE_LOGIN  => 'https://www.ite.logon.realme.govt.nz/saml2',
143
            self::TYPE_ASSERT => 'https://www.ite.account.realme.govt.nz/saml2/assertion',
144
        ),
145
        self::ENV_PROD => array(
146
            self::TYPE_LOGIN  => 'https://www.logon.realme.govt.nz/saml2',
147
            self::TYPE_ASSERT => 'https://www.account.realme.govt.nz/saml2/assertion',
148
        )
149
    );
150
151
    private static $idp_sso_service_urls = array(
152
        self::ENV_MTS => array(
153
            self::TYPE_LOGIN  => 'https://mts.realme.govt.nz/logon-mts/mtsEntryPoint',
154
            self::TYPE_ASSERT => 'https://mts.realme.govt.nz/realme-mts/validate/realme-mts-idp.xhtml'
155
        ),
156
        self::ENV_ITE => array(
157
            self::TYPE_LOGIN  => 'https://www.ite.logon.realme.govt.nz/sso/logon/metaAlias/logon/logonidp',
158
            self::TYPE_ASSERT => 'https://www.ite.assert.realme.govt.nz/sso/SSORedirect/metaAlias/assertion/realmeidp'
159
        ),
160
        self::ENV_PROD => array(
161
            self::TYPE_LOGIN  => 'https://www.logon.realme.govt.nz/sso/logon/metaAlias/logon/logonidp',
162
            self::TYPE_ASSERT => 'https://www.assert.realme.govt.nz/sso/SSORedirect/metaAlias/assertion/realmeidp'
163
        )
164
    );
165
166
    /**
167
     * @var array A list of certificate filenames for different RealMe environments and integration types. These files
168
     * must be located in the directory specified by the REALME_CERT_DIR environment variable. These filenames are the
169
     * same as the files that can be found in the RealMe Shared Workspace, within the 'Integration Bundle' ZIP files for
170
     * the different environments (MTS, ITE and Production), so you just need to extract the specific certificate file
171
     * that you need and make sure it's in place on the server in the REALME_CERT_DIR.
172
     */
173
    private static $idp_x509_cert_filenames = array(
174
        self::ENV_MTS => array(
175
            self::TYPE_LOGIN  => 'mts_login_saml_idp.cer',
176
            self::TYPE_ASSERT => 'mts_assert_saml_idp.cer'
177
        ),
178
        self::ENV_ITE => array(
179
            self::TYPE_LOGIN  => 'ite.signing.logon.realme.govt.nz.cer',
180
            self::TYPE_ASSERT => 'ite.signing.account.realme.govt.nz.cer'
181
        ),
182
        self::ENV_PROD => array(
183
            self::TYPE_LOGIN  => 'signing.logon.realme.govt.nz.cer',
184
            self::TYPE_ASSERT => 'signing.account.realme.govt.nz.cer'
185
        )
186
    );
187
188
    /**
189
     * @config
190
     * @var array Stores the AuthN context values for each supported RealMe environment. This needs to be setup prior to
191
     * running the `RealMeSetupTask` build task. For more information, see the module documentation. An AuthN context
192
     * can be one of the following:
193
     *
194
     * Username and password only:
195
     * - urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:LowStrength
196
     *
197
     * Username, password, and any moderate strength second level of authenticator (RSA token, Google Auth, SMS)
198
     * - urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:ModStrength
199
     *
200
     * The following two are less often used, and shouldn't be used unless there's a specific need.
201
     *
202
     * Username, password, and only SMS 2FA token
203
     * - urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:ModStrength::OTP:Mobile:SMS
204
     *
205
     * Username, password, and only RSA 2FA token
206
     * - urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:ModStrength::OTP:Token:SID
207
     */
208
    private static $authn_contexts = array(
209
        self::ENV_MTS => null,
210
        self::ENV_ITE => null,
211
        self::ENV_PROD => null
212
    );
213
214
    /**
215
     * @config $allowed_authn_context_list
216
     * @var $allowed_authn_context_list array
217
     *
218
     * A list of the valid authn context values supported for realme.
219
     */
220
    private static $allowed_authn_context_list = array(
221
        self::AUTHN_LOW_STRENGTH,
222
        self::AUTHN_MOD_STRENTH,
223
        self::AUTHN_MOD_MOBILE_SMS,
224
        self::AUTHN_MOD_TOKEN_SID
225
    );
226
227
    /**
228
     * @config
229
     * @var array Domain names for metadata files. Used in @link RealMeSetupTask when outputting metadata XML
230
     */
231
    private static $metadata_assertion_service_domains = array(
232
        self::ENV_MTS => null,
233
        self::ENV_ITE => null,
234
        self::ENV_PROD => null
235
    );
236
237
    /**
238
     * @config
239
     * @var array A list of error messages to display if RealMe returns error statuses, instead of the default
240
     * translations (found in realme/lang/en.yml for example).
241
     */
242
    private static $realme_error_message_overrides = array(
243
        self::ERR_AUTHN_FAILED => null,
244
        self::ERR_TIMEOUT => null,
245
        self::ERR_INTERNAL_ERROR => null,
246
        self::ERR_NO_AVAILABLE_IDP => null,
247
        self::ERR_REQUEST_UNSUPPORTED => null,
248
        self::ERR_NO_PASSIVE => null,
249
        self::ERR_REQUEST_DENIED => null,
250
        self::ERR_UNSUPPORTED_BINDING => null,
251
        self::ERR_UNKNOWN_PRINCIPAL => null,
252
        self::ERR_NO_AUTHN_CONTEXT => null
253
    );
254
255
    /**
256
     * @config
257
     * @var string|null The organisation name to be used in metadata XML that is submitted to RealMe
258
     */
259
    private static $metadata_organisation_name = null;
260
261
    /**
262
     * @config
263
     * @var string|null The organisation display name to be used in metadata XML that is submitted to RealMe
264
     */
265
    private static $metadata_organisation_display_name = null;
266
267
    /**
268
     * @config
269
     * @var string|null The organisation URL to be used in metadata XML that is submitted to RealMe
270
     */
271
    private static $metadata_organisation_url = null;
272
273
    /**
274
     * @config
275
     * @var string|null The support contact's company name to be used in metadata XML that is submitted to RealMe
276
     */
277
    private static $metadata_contact_support_company = null;
278
279
    /**
280
     * @config
281
     * @var string|null The support contact's first name(s) to be used in metadata XML that is submitted to RealMe
282
     */
283
    private static $metadata_contact_support_firstnames = null;
284
285
    /**
286
     * @config
287
     * @var string|null The support contact's surname to be used in metadata XML that is submitted to RealMe
288
     */
289
    private static $metadata_contact_support_surname = null;
290
291
    /**
292
     * @var OneLogin_Saml2_Auth|null Set by {@link getAuth()}, which creates an instance of OneLogin_Saml2_Auth to check
293
     * authentication against
294
     */
295
    private $auth = null;
296
297
    /**
298
     * @var string|null The last error message during login enforcement
299
     */
300
    private $lastError = null;
301
302
    /**
303
     * @return array
304
     */
305
    public static function get_template_global_variables()
306
    {
307
        return array(
308
            'RealMeUser' => array(
309
                'method' => 'current_realme_user'
310
            )
311
        );
312
    }
313
314
    /**
315
     * Return the user data which was saved to session from the first RealMe
316
     * auth.
317
     * Note: Does not check authenticity or expiry of this data
318
     *
319
     * @param HTTPRequest $request
320
     * @return User
321
     */
322
    public static function user_data()
323
    {
324
        if (!is_null(static::$user_data)) {
325
            return static::$user_data;
326
        }
327
328
        $request = Injector::inst()->get(HTTPRequest::class);
329
        $sessionData = $request->getSession()->get('RealMe.SessionData');
330
331
        // Exit point
332
        if (is_null($sessionData)) {
333
            return null;
334
        }
335
336
        // Unserialise stored data
337
        $user = unserialize($sessionData);
338
339
        if ($user == false || !$user instanceof User) {
340
            return null;
341
        }
342
343
        static::$user_data = $user;
344
        return static::$user_data;
345
    }
346
347
    public function getUserData()
348
    {
349
        return static::user_data();
350
    }
351
352
    /**
353
     * Calls available user data and checks for validity
354
     *
355
     * @return User
356
     */
357
    public static function current_realme_user()
358
    {
359
        $user = self::user_data();
360
        if ($user && !$user->isValid()) {
361
            return null;
362
        }
363
364
        return $user;
365
    }
366
367
    /**
368
     * A helpful static method that follows SilverStripe naming for Member::currentUser();
369
     *
370
     * @return User
371
     */
372
    public static function currentRealMeUser()
373
    {
374
        return self::current_realme_user();
375
    }
376
377
    /**
378
     * Enforce login via RealMe. This can be used in controllers to force users to be authenticated via RealMe (not
379
     * necessarily logged in as a {@link Member}), in the form of:
380
     * <code>
381
     * Session::set('RealMeBackURL', '/path/to/the/controller/method');
382
     * if($service->enforceLogin()) {
383
     *     // User has a valid RealMe account, $service->getAuthData() will return you their details
384
     * } else {
385
     *     // Something went wrong processing their details, show an error
386
     * }
387
     * </code>
388
     *
389
     * In cases where people are *not* authenticated with RealMe, this method will redirect them directly to RealMe.
390
     *
391
     * However, generally you want this to be an explicit process, so you should look at instead using the standard
392
     * {@link RealMeAuthenticator}.
393
     *
394
     * A return value of bool false indicates that there was a failure during the authentication process (perhaps a
395
     * communication issue, or a failure to decode the response correctly. You should handle this like you would any
396
     * other unexpected authentication error. You can use {@link getLastError()} to see if a human-readable error
397
     * message exists for display to the user.
398
     *
399
     * @param HTTPRequest $request
400
     * @param string $backUrl
401
     * @return bool|null true if the user is correctly authenticated, false if there was an error with login
402
     * @throws OneLogin_Saml2_Error
403
     */
404
    public function enforceLogin(HTTPRequest $request, $backUrl = null)
405
    {
406
        // First, check to see if we have an existing authenticated session
407
        if ($this->isAuthenticated()) {
408
            return true;
409
        }
410
411
        $session = $request->getSession();
412
413
        if ($backUrl) {
414
            $session->set('RealMeBackURL', $this->validSiteURL($backUrl));
415
        }
416
417
        // If not, attempt to retrieve authentication data from OneLogin (in case this is called during SAML assertion)
418
        try {
419
            if (!$session->get("RealMeErrorBackURL")) {
420
                $session->set("RealMeErrorBackURL", Controller::curr()->Link("Login"));
421
            }
422
423
            $auth = $this->getAuth();
424
            $auth->processResponse();
425
426
            // if there were any errors from the SAML request, process and translate them.
427
            $errors = $auth->getErrors();
428
            if (is_array($errors) && !empty($errors)) {
429
                $this->processSamlErrors($errors);
430
                return false;
431
            }
432
433
            $authData = $this->getAuthData();
434
435
            // If no data is found, then force login
436
            if (is_null($authData)) {
437
                throw new RealMeException('No SAML data, enforcing login', RealMeException::NOT_AUTHENTICATED);
438
            }
439
440
            // call a success method as we've successfully logged in (if it exists)
441
            Member::singleton()->extend('onRealMeLoginSuccess', $authData);
442
        } catch (Exception $e) {
443
            Member::singleton()->extend("onRealMeLoginFailure", $e);
444
445
            // No auth data or failed to decrypt, enforce login again
446
            $auth->login(Director::absoluteBaseURL());
447
            die;
448
        }
449
450
        return $auth->isAuthenticated();
451
    }
452
453
    /**
454
     * If there was an error returned from the saml response, process the errors
455
     *
456
     * @param $errors
457
     */
458
    private function processSamlErrors(array $errors)
459
    {
460
        $translatedMessage = null;
461
462
        // The error message returned by onelogin/php-saml is the top-level error, but we want the actual error
463
        $request = Controller::curr()->getRequest();
464
        if ($request->isPOST() && $request->postVar("SAMLResponse")) {
465
            $response = new OneLogin_Saml2_Response($this->getAuth()->getSettings(), $request->postVar("SAMLResponse"));
466
            $internalError = OneLogin_Saml2_Utils::query(
467
                $response->document,
468
                "/samlp:Response/samlp:Status/samlp:StatusCode/samlp:StatusCode/@Value"
469
            );
470
471
            if ($internalError instanceof DOMNodeList && $internalError->length > 0) {
472
                $internalErrorCode = $internalError->item(0)->textContent;
473
                $translatedMessage = $this->findErrorMessageForCode($internalErrorCode);
474
            }
475
        }
476
477
        // If we found a message to display, then let's redirect to the form and display it
478
        if ($translatedMessage) {
479
            $this->lastError = $translatedMessage;
480
        }
481
482
        Injector::inst()->get(LoggerInterface::class)->info(sprintf(
483
            'onelogin/php-saml error messages: %s (%s)',
484
            join(', ', $errors),
485
            $this->getAuth()->getLastErrorReason()
486
        ));
487
    }
488
489
    /**
490
     * Checks data stored in Session to see if the user is authenticated.
491
     * @return bool true if the user is authenticated via RealMe and we can trust ->getUserData()
492
     */
493
    public function isAuthenticated()
494
    {
495
        $user = $this->getUserData();
496
        return $user instanceof User && $user->isAuthenticated();
497
    }
498
499
    /**
500
     * Returns a {@link RealMeUser} object if one can be built from the RealMe session data.
501
     *
502
     * @throws OneLogin_Saml2_Error Passes on the SAML error if it's not indicating a lack of SAML response data
503
     * @throws RealMeException If identity information exists but couldn't be decoded, or doesn't exist
504
     * @return User|null
505
     */
506
    public function getAuthData()
507
    {
508
        // returns null if the current auth is invalid or timed out.
509
        try {
510
            // Process response and capture details
511
            $auth = $this->getAuth();
512
513
            if (!$auth->isAuthenticated()) {
514
                throw new RealMeException(
515
                    'OneLogin SAML library did not successfully authenticate, but did not return a specific error',
516
                    RealMeException::NOT_AUTHENTICATED
517
                );
518
            }
519
520
            $spNameId = $auth->getNameId();
521
            if (!is_string($spNameId)) {
522
                throw new RealMeException('Invalid/Missing NameID in SAML response', RealMeException::MISSING_NAMEID);
523
            }
524
525
            $sessionIndex = $auth->getSessionIndex();
526
            if (!is_string($sessionIndex)) {
527
                throw new RealMeException(
528
                    'Invalid/Missing SessionIndex value in SAML response',
529
                    RealMeException::MISSING_SESSION_INDEX
530
                );
531
            }
532
533
            $attributes = $auth->getAttributes();
534
            if (!is_array($attributes)) {
535
                throw new RealMeException(
536
                    'Invalid/Missing attributes array in SAML response',
537
                    RealMeException::MISSING_ATTRIBUTES
538
                );
539
            }
540
541
            $federatedIdentity = $this->retrieveFederatedIdentity($auth);
542
543
            // We will have either a FLT or FIT, depending on integration type
544
            if ($this->config()->integration_type == self::TYPE_ASSERT) {
545
                $userTag = $this->retrieveFederatedIdentityTag($auth);
546
            } else {
547
                $userTag = $this->retrieveFederatedLogonTag($auth);
548
            }
549
550
            return User::create([
551
                'SPNameID' => $spNameId,
552
                'UserFederatedTag' => $userTag,
553
                'SessionIndex' => $sessionIndex,
554
                'Attributes' => $attributes,
555
                'FederatedIdentity' => $federatedIdentity,
556
            ]);
557
        } catch (OneLogin_Saml2_Error $e) {
558
            // If the Exception code indicates there wasn't a response, we ignore it as it simply means the visitor
559
            // isn't authenticated yet. Otherwise, we re-throw the Exception
560
            if ($e->getCode() === OneLogin_Saml2_Error::SAML_RESPONSE_NOT_FOUND) {
561
                return null;
562
            } else {
563
                throw $e;
564
            }
565
        }
566
    }
567
568
    /**
569
     * Clear the RealMe credentials from Session, called during Security->logout() overrides
570
     *
571
     * @param HTTPRequest $request
572
     * @return void
573
     */
574
    public function clearLogin(HTTPRequest $request)
575
    {
576
        $this->config()->__set('user_data', null);
577
        $session = $request->getSession();
578
579
        $session->set("RealMeBackURL", null);
580
        $session->set("RealMeErrorBackURL", null);
581
        $session->set("RealMe.SessionData", null);
582
        $session->set("RealMe.OriginalResponse", null);
583
        $session->set("RealMe.LastErrorMessage", null);
584
    }
585
586
    public function getLastError()
587
    {
588
        return $this->lastError;
589
    }
590
591
    /**
592
     * @return string A BackURL as specified originally when accessing /Security/login, for use after authentication
593
     */
594
    public function getBackURL(HTTPRequest $request)
595
    {
596
        $url = null;
597
        $session = $request->getSession();
598
599
        if ($session->get('RealMeBackURL')) {
600
            $url = $session->get('RealMeBackURL');
601
            $session->clear('RealMeBackURL'); // Ensure we don't redirect back to the same error twice
602
        }
603
604
        return $this->validSiteURL($url);
605
    }
606
607
    public function getErrorBackURL(HTTPRequest $request)
608
    {
609
        $url = null;
610
        $session = $request->getSession();
611
612
        if ($session->get('RealMeErrorBackURL')) {
613
            $url = $session->get('RealMeErrorBackURL');
614
            $session->clear('RealMeErrorBackURL'); // Ensure we don't redirect back to the same error twice
615
        }
616
617
        return $this->validSiteURL($url);
618
    }
619
620
    private function validSiteURL($url = null)
621
    {
622
        if (isset($url) && Director::is_site_url($url)) {
623
            $url = Director::absoluteURL($url);
624
        } else {
625
            // Spoofing attack or no back URL set, redirect to homepage instead of spoofing url
626
            $url = Director::absoluteBaseURL();
627
        }
628
629
        return $url;
630
    }
631
632
    /**
633
     * @param String $subdir A sub-directory where certificates may be stored for
634
     * a specific case
635
     * @return string|null Either the directory where certificates are stored,
636
     * or null if undefined
637
     */
638
    public function getCertDir($subdir = null)
639
    {
640
641
        // Trim prepended seprator to avoid absolute path
642
        $path = ltrim(ltrim($subdir, '/'), '\\');
643
644
        if ($certDir = Environment::getEnv('REALME_CERT_DIR')) {
645
            $path = $certDir . '/' . $path; // Duplicate slashes will be handled by realpath()
646
        }
647
648
        return realpath($path);
649
    }
650
651
    /**
652
     * Returns the appropriate AuthN Context, given the environment passed in. The AuthNContext may be different per
653
     * environment, and should be one of the strings as defined in the static {@link self::$authn_contexts} at the top
654
     * of this class.
655
     *
656
     * @param string $env The environment to return the AuthNContext for. Must be one of the RealMe environment names
657
     * @return string|null Returns the AuthNContext for the given $env, or null if no context exists
658
     */
659
    public function getAuthnContextForEnvironment($env)
660
    {
661
        return $this->getConfigurationVarByEnv('authn_contexts', $env);
662
    }
663
664
    /**
665
     * Returns the full path to the SAML signing certificate file, used by SimpleSAMLphp to sign all messages sent to
666
     * RealMe.
667
     *
668
     * @return string|null Either the full path to the SAML signing certificate file, or null if it doesn't exist
669
     */
670
    public function getSigningCertPath()
671
    {
672
        return $this->getCertPath('SIGNING');
673
    }
674
675
    public function getIdPCertPath()
676
    {
677
        $cfg = $this->config();
678
        $name = $this->getConfigurationVarByEnv('idp_x509_cert_filenames', $cfg->realme_env, $cfg->integration_type);
679
680
        return $this->getCertDir($name);
681
    }
682
683
    public function getSPCertContent($contentType = 'certificate')
684
    {
685
        return $this->getCertificateContents($this->getSigningCertPath(), $contentType);
686
    }
687
688
    public function getIdPCertContent()
689
    {
690
        return $this->getCertificateContents($this->getIdPCertPath());
691
    }
692
693
    /**
694
     * Returns the content of the SAML signing certificate. This is used by getAuth() and by RealMeSetupTask to produce
695
     * metadata XML files.
696
     *
697
     * @param string $certPath The filesystem path to where the certificate is stored on the filesystem
698
     * @param string $contentType Either 'certificate' or 'key', depending on which part of the file to return
699
     * @return string|null The content of the signing certificate
700
     */
701
    public function getCertificateContents($certPath, $contentType = 'certificate')
702
    {
703
        $text = null;
704
705
        if (!is_null($certPath)) {
706
            $certificateContents = file_get_contents($certPath);
707
708
            // If the file does not contain any header information and the content type is certificate, just return it
709
            if ($contentType == 'certificate' && !preg_match('/-----BEGIN/', $certificateContents)) {
710
                $text = $certificateContents;
711
            } else {
712
                // Otherwise, inspect the file and match based on the full contents
713
                if ($contentType == 'certificate') {
714
                    $pattern = '/-----BEGIN CERTIFICATE-----[\r\n]*([^-]*)[\r\n]*-----END CERTIFICATE-----/';
715
                } elseif ($contentType == 'key') {
716
                    $pattern = '/-----BEGIN [A-Z ]*PRIVATE KEY-----\n([^-]*)\n-----END [A-Z ]*PRIVATE KEY-----/';
717
                } else {
718
                    throw new InvalidArgumentException('Argument contentType must be either "certificate" or "key"');
719
                }
720
721
                // This is a PEM key, and we need to extract just the certificate, stripping out the private key etc.
722
                // So we search for everything between '-----BEGIN CERTIFICATE-----' and '-----END CERTIFICATE-----'
723
                preg_match(
724
                    $pattern,
725
                    $certificateContents,
726
                    $matches
727
                );
728
729
                if (isset($matches) && is_array($matches) && isset($matches[1])) {
730
                    $text = trim($matches[1]);
731
                }
732
            }
733
        }
734
735
        return $text;
736
    }
737
738
    /**
739
     * @param string $env The environment to return the entity ID for. Must be one of the RealMe environment names
740
     * @return string|null Either the assertion consumer service location, or null if information doesn't exist
741
     */
742
    public function getAssertionConsumerServiceUrlForEnvironment($env)
743
    {
744
        if (in_array($env, $this->getAllowedRealMeEnvironments()) === false) {
745
            return null;
746
        }
747
748
        $domain = $this->getMetadataAssertionServiceDomainForEnvironment($env);
749
        if (filter_var($domain, FILTER_VALIDATE_URL) === false) {
750
            return null;
751
        }
752
753
        // Returns https://domain.govt.nz/Security/login/RealMe/acs
754
        return Controller::join_links($domain, Security::config()->get('login_url'), 'RealMe/acs');
755
    }
756
757
    /**
758
     * @return string|null The organisation name to be used in metadata XML output, or null if none exists
759
     */
760
    public function getMetadataOrganisationName()
761
    {
762
        $orgName = $this->config()->metadata_organisation_name;
763
        return (strlen($orgName) > 0) ? $orgName : null;
764
    }
765
766
    /**
767
     * @return string|null The organisation display name to be used in metadata XML output, or null if none exists
768
     */
769
    public function getMetadataOrganisationDisplayName()
770
    {
771
        $displayName = $this->config()->metadata_organisation_display_name;
772
        return (strlen($displayName) > 0) ? $displayName : null;
773
    }
774
775
    /**
776
     * @return string|null The organisation website URL to be used in metadata XML output, or null if none exists
777
     */
778
    public function getMetadataOrganisationUrl()
779
    {
780
        $url = $this->config()->metadata_organisation_url;
781
        return (strlen($url) > 0) ? $url: null;
782
    }
783
784
    /**
785
     * @return string[] The support contact details to be used in metadata XML output, with null values if they don't
786
     *                  exist
787
     */
788
    public function getMetadataContactSupport()
789
    {
790
        $company = $this->config()->metadata_contact_support_company;
791
        $firstNames = $this->config()->metadata_contact_support_firstnames;
792
        $surname = $this->config()->metadata_contact_support_surname;
793
794
        return array(
795
            'company' => (strlen($company) > 0) ? $company : null,
796
            'firstNames' => (strlen($firstNames) > 0) ? $firstNames : null,
797
            'surname' => (strlen($surname) > 0) ? $surname : null
798
        );
799
    }
800
801
    /**
802
     * The list of RealMe environments that can be used. By default, we allow mts, ite and production.
803
     * @return array
804
     */
805
    public function getAllowedRealMeEnvironments()
806
    {
807
        return $this->config()->allowed_realme_environments;
808
    }
809
810
    /**
811
     * The list of valid realme AuthNContexts
812
     * @return array
813
     */
814
    public function getAllowedAuthNContextList()
815
    {
816
        return $this->config()->allowed_authn_context_list;
817
    }
818
819
    /**
820
     * Returns the appropriate entity ID for RealMe, given the environment passed in. The entity ID may be different per
821
     * environment, and should be a full URL, including privacy realm and application name. For example, this may be:
822
     * https://www.agency.govt.nz/privacy-realm-name/application-name
823
     *
824
     * @return string|null Returns the entity ID for the current environment, or null if no entity ID exists
825
     */
826
    public function getSPEntityID()
827
    {
828
        return $this->getConfigurationVarByEnv('sp_entity_ids', $this->config()->realme_env);
829
    }
830
831
    private function getIdPEntityID()
832
    {
833
        $cfg = $this->config();
834
        return $this->getConfigurationVarByEnv('idp_entity_ids', $cfg->realme_env, $cfg->integration_type);
835
    }
836
837
    private function getSingleSignOnServiceURL()
838
    {
839
        $cfg = $this->config();
840
        return $this->getConfigurationVarByEnv('idp_sso_service_urls', $cfg->realme_env, $cfg->integration_type);
841
    }
842
843
    private function getRequestedAuthnContext()
844
    {
845
        return $this->getConfigurationVarByEnv('authn_contexts', $this->config()->realme_env);
846
    }
847
848
    /**
849
     * Returns the internal {@link OneLogin_Saml2_Auth} object against which visitors are authenticated.
850
     *
851
     * @return OneLogin_Saml2_Auth
852
     */
853
    public function getAuth(HTTPRequest $request = null)
854
    {
855
        if (isset($this->auth)) {
856
            return $this->auth;
857
        }
858
859
        if (!$request) {
860
            $request = Injector::inst()->get(HTTPRequest::class);
861
        }
862
863
        // Ensure onelogin is using the correct host, protocol and port incase a proxy is involved
864
        OneLogin_Saml2_Utils::setSelfHost($request->getHeader('Host'));
865
        OneLogin_Saml2_Utils::setSelfProtocol($request->getScheme());
866
867
        $port = null;
868
        if (isset($_SERVER['HTTP_X_FORWARDED_PORT'])) {
869
            $port = $_SERVER['HTTP_X_FORWARDED_PORT'];
870
        } elseif (isset($_SERVER['SERVER_PORT'])) {
871
            $port = $_SERVER['SERVER_PORT'];
872
        }
873
874
        if ($port) {
875
            OneLogin_Saml2_Utils::setSelfPort($port);
876
        }
877
878
        $settings = [
879
            'strict' => true,
880
            'debug' => false,
881
882
            // Service Provider (this installation) configuration
883
            'sp' => [
884
                'entityId' => $this->getSPEntityID(),
885
                'x509cert' => $this->getSPCertContent('certificate'),
886
                'privateKey' => $this->getSPCertContent('key'),
887
888
                // According to RealMe messaging spec, must always be transient for assert; is irrelevant for login
889
                'NameIDFormat' => $this->getNameIdFormat(),
890
891
                'assertionConsumerService' => [
892
                    'url' => $this->getAssertionConsumerServiceUrlForEnvironment($this->config()->realme_env),
893
                    'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' // Always POST, not artifact binding
894
                ]
895
            ],
896
897
            // RealMe Identity Provider configuration
898
            'idp' => [
899
                'entityId' => $this->getIdPEntityID(),
900
                'x509cert' => $this->getIdPCertContent(),
901
902
                'singleSignOnService' => [
903
                    'url' => $this->getSingleSignOnServiceURL(),
904
                    'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
905
                ]
906
            ],
907
908
            'security' => [
909
                'signatureAlgorithm' => 'http://www.w3.org/2000/09/xmldsig#rsa-sha1',
910
                'authnRequestsSigned' => true,
911
                'wantAssertionsEncrypted' => true,
912
                'wantAssertionsSigned' => true,
913
914
                'requestedAuthnContext' => [
915
                    $this->getRequestedAuthnContext()
916
                ]
917
            ]
918
        ];
919
920
        $this->auth = new OneLogin_Saml2_Auth($settings);
921
        return $this->auth;
922
    }
923
924
    /**
925
     * @return string the required NameIDFormat to be included in metadata XML, based on the requested integration type
926
     */
927
    public function getNameIdFormat()
928
    {
929
        switch ($this->config()->integration_type) {
930
            case self::TYPE_ASSERT:
931
                return 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient';
932
                break;
933
934
            case self::TYPE_LOGIN:
935
            default:
936
                return 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
937
                break;
938
        }
939
    }
940
941
    /**
942
     * @param string $cfgName The static configuration value to get. This should be an array
943
     * @param string $env The environment to return the value for. Must be one of the RealMe environment names
944
     * @param string $integrationType The integration type (login or assert), if necessary, to determine return var
945
     * @throws InvalidArgumentException If the cfgVar doesn't exist, or is malformed
946
     * @return string|null Returns the value as defined in $cfgName for the given environment, or null if none exist
947
     */
948
    private function getConfigurationVarByEnv($cfgName, $env, $integrationType = null)
949
    {
950
        $value = null;
951
952
        if (in_array($env, $this->getAllowedRealMeEnvironments())) {
953
            $values = $this->config()->$cfgName;
954
955
            if (is_array($values) && isset($values[$env])) {
956
                $value = $values[$env];
957
            }
958
        }
959
960
        // If $integrationType is specified, then $value should be an array, with the array key being the integration
961
        // type and array value being the returned variable
962
        if (!is_null($integrationType) && is_array($value) && isset($value[$integrationType])) {
963
            $value = $value[$integrationType];
964
        } elseif (!is_null($integrationType)) {
965
            // Otherwise, we are expecting an integration type, but the value is not specified that way, error out
966
            throw new InvalidArgumentException(
967
                sprintf(
968
                    'Config value %s[%s][%s] not well formed (cfg var not an array)',
969
                    $cfgName,
970
                    $env,
971
                    $integrationType
972
                )
973
            );
974
        }
975
976
        if (is_null($value)) {
977
            throw new InvalidArgumentException(sprintf('Config value %s[%s] not set', $cfgName, $env));
978
        }
979
980
        return $value;
981
    }
982
983
    /**
984
     * @param string $certName The certificate name, either 'SIGNING' or 'MUTUAL'
985
     * @return string|null Either the full path to the certificate file, or null if it doesn't exist
986
     * @see self::getSigningCertPath()
987
     */
988
    private function getCertPath($certName)
989
    {
990
        $certPath = null;
991
992
        if (in_array($certName, array('SIGNING', 'MUTUAL'))) {
993
            $constName = sprintf('REALME_%s_CERT_FILENAME', strtoupper($certName));
994
            if ($filename = Environment::getEnv($constName)) {
995
                $certPath = $this->getCertDir($filename);
996
            }
997
        }
998
999
        // Ensure the file exists, if it doesn't then set it to null
1000
        if (!is_null($certPath) && !file_exists($certPath)) {
1001
            $certPath = null;
1002
        }
1003
1004
        return $certPath;
1005
    }
1006
1007
    /**
1008
     * @param string $env The environment to return the domain name for. Must be one of the RealMe environment names
1009
     * @return string|null Either the FQDN (e.g. https://www.realme-demo.govt.nz/) or null if none is specified
1010
     */
1011
    private function getMetadataAssertionServiceDomainForEnvironment($env)
1012
    {
1013
        return $this->getConfigurationVarByEnv('metadata_assertion_service_domains', $env);
1014
    }
1015
1016
    /**
1017
     * @param OneLogin_Saml2_Auth $auth
1018
     * @return string|null null if there's no FLT, or a string if there is one
1019
     */
1020
    private function retrieveFederatedLogonTag(OneLogin_Saml2_Auth $auth)
1021
    {
1022
        return null; // @todo
1023
    }
1024
1025
    /**
1026
     * @param OneLogin_Saml2_Auth $auth
1027
     * @return string|null null if there's not FIT, or a string if there is one
1028
     */
1029
    private function retrieveFederatedIdentityTag(OneLogin_Saml2_Auth $auth)
1030
    {
1031
        $fit = null;
1032
        $attributes = $auth->getAttributes();
1033
1034
        if (isset($attributes['urn:nzl:govt:ict:stds:authn:attribute:igovt:IVS:FIT'])) {
1035
            $fit = $attributes['urn:nzl:govt:ict:stds:authn:attribute:igovt:IVS:FIT'][0];
1036
        }
1037
1038
        return $fit;
1039
    }
1040
1041
    /**
1042
     * @param OneLogin_Saml2_Auth $auth
1043
     * @return FederatedIdentity|null
1044
     * @throws RealMeException
1045
     */
1046
    private function retrieveFederatedIdentity(OneLogin_Saml2_Auth $auth)
1047
    {
1048
        $federatedIdentity = null;
1049
        $attributes = $auth->getAttributes();
1050
        $nameId = $auth->getNameId();
1051
1052
        // If identity information exists, retrieve the FIT (Federated Identity Tag) and identity data
1053
        if (isset($attributes['urn:nzl:govt:ict:stds:authn:safeb64:attribute:igovt:IVS:Assertion:Identity'])) {
1054
            // Identity information is encoded using 'Base 64 Encoding with URL and Filename Safe Alphabet'
1055
            // For more info, review RFC3548, section 4 (https://tools.ietf.org/html/rfc3548#page-6)
1056
            // Note: This is different to PHP's standard base64_decode() function, therefore we need to swap chars
1057
            // to match PHP's expectations:
1058
            // char 62 (-) becomes +
1059
            // char 63 (_) becomes /
1060
1061
            $identity = $attributes['urn:nzl:govt:ict:stds:authn:safeb64:attribute:igovt:IVS:Assertion:Identity'];
1062
1063
            if (!is_array($identity) || !isset($identity[0])) {
1064
                throw new RealMeException(
1065
                    'Invalid identity response received from RealMe',
1066
                    RealMeException::INVALID_IDENTITY_VALUE
1067
                );
1068
            }
1069
1070
            // Switch from filename-safe alphabet base64 encoding to standard base64 encoding
1071
            $identity = strtr($identity[0], '-_', '+/');
1072
            $identity = base64_decode($identity, true);
1073
1074
            if (is_bool($identity) && !$identity) {
1075
                // Strict base64_decode fails, either the identity didn't exist or was mangled during transmission
1076
                throw new RealMeException(
1077
                    'Failed to parse safe base64 encoded identity',
1078
                    RealMeException::FAILED_PARSING_IDENTITY
1079
                );
1080
            }
1081
1082
            $identityDoc = new DOMDocument();
1083
            if ($identityDoc->loadXML($identity)) {
1084
                $federatedIdentity = new FederatedIdentity($identityDoc, $nameId);
1085
            }
1086
        }
1087
1088
        return $federatedIdentity;
1089
    }
1090
1091
    /**
1092
     * Finds a human-readable error message based on the error code provided in the RealMe SAML response
1093
     *
1094
     * @return string|null The human-readable error message, or null if one can't be found
1095
     */
1096
    private function findErrorMessageForCode($errorCode)
1097
    {
1098
        $message = null;
1099
        $messageOverrides = $this->config()->realme_error_message_overrides;
1100
1101
        switch ($errorCode) {
1102
            case self::ERR_AUTHN_FAILED:
1103
                $message = _t(self::class . '.ERROR_AUTHNFAILED', 'You have chosen to leave RealMe.');
1104
                break;
1105
1106
            case self::ERR_TIMEOUT:
1107
                $message = _t(self::class . '.ERROR_TIMEOUT', 'Your RealMe session has timed out – please try again.');
1108
                break;
1109
1110
            case self::ERR_INTERNAL_ERROR:
1111
                $message = _t(
1112
                    self::class . '.ERROR_INTERNAL',
1113
                    'RealMe was unable to process your request due to a RealMe internal error. Please try again. ' .
1114
                        'If the problem persists, please contact the RealMe Help Desk. From New Zealand dial ' .
1115
                        '0800 664 774 (toll free), from overseas dial +64 9 357 4468 (overseas call charges apply).'
1116
                );
1117
                break;
1118
1119
            case self::ERR_NO_AVAILABLE_IDP:
1120
                $message = _t(
1121
                    self::class . '.ERROR_NOAVAILABLEIDP',
1122
                    'RealMe reported that the TXT service or the token service is not available. You may try again ' .
1123
                        'later. If the problem persists, please contact the RealMe Help Desk. From New Zealand dial ' .
1124
                        '0800 664 774 (toll free), from overseas dial +64 9 357 4468 (overseas call charges apply).'
1125
                );
1126
                break;
1127
1128
            case self::ERR_REQUEST_UNSUPPORTED:
1129
                $message = _t(
1130
                    self::class . '.ERROR_REQUESTUNSUPPORTED',
1131
                    'RealMe reported a serious application error with the message \'Request Unsupported\'. Please try' .
1132
                        ' again later. If the problem persists, please contact the RealMe Help Desk. From New Zealand' .
1133
                        ': 0800 664 774 (toll free), from overseas dial +64 9 357 4468 (overseas call charges apply).'
1134
                );
1135
                break;
1136
1137
            case self::ERR_NO_PASSIVE:
1138
                $message = _t(
1139
                    self::class . '.ERROR_NOPASSIVE',
1140
                    'RealMe reported a serious application error with the message \'No Passive\'. Please try again ' .
1141
                        'later. If the problem persists, please contact the RealMe Help Desk. From New Zealand: 0800 ' .
1142
                        '664 774 (toll free), from overseas dial +64 9 357 4468 (overseas call charges apply).'
1143
                );
1144
                break;
1145
1146
            case self::ERR_REQUEST_DENIED:
1147
                $message = _t(
1148
                    self::class . '.ERROR_REQUESTDENIED',
1149
                    'RealMe reported a serious application error with the message \'Request Denied\'. Please try ' .
1150
                        'again later. If the problem persists, please contact the RealMe Help Desk. From New Zealand:' .
1151
                        ' 0800 664 774 (toll free), from overseas dial +64 9 357 4468 (overseas call charges apply).'
1152
                );
1153
                break;
1154
1155
            case self::ERR_UNSUPPORTED_BINDING:
1156
                $message = _t(
1157
                    self::class . '.ERROR_UNSUPPORTEDBINDING',
1158
                    'RealMe reported a serious application error with the message \'Unsupported Binding\'. Please ' .
1159
                        'try again later. If the problem persists, please contact the RealMe Help Desk. From New ' .
1160
                        'Zealand: 0800 664 774 (toll free), from overseas dial +64 9 357 4468 (overseas call charges ' .
1161
                        'apply).'
1162
                );
1163
                break;
1164
1165
            case self::ERR_UNKNOWN_PRINCIPAL:
1166
                $message = _t(
1167
                    self::class . '.ERROR_UNKNOWNPRINCIPAL',
1168
                    'You are unable to use RealMe to verify your identity if you do not have a RealMe account. ' .
1169
                        'Visit the RealMe home page for more information and to create an account.'
1170
                );
1171
                break;
1172
1173
            case self::ERR_NO_AUTHN_CONTEXT:
1174
                $message = _t(
1175
                    self::class . '.ERROR_NOAUTHNCONTEXT',
1176
                    'RealMe reported a serious application error with the message \'No AuthN Context\'. Please try ' .
1177
                        'again later. If the problem persists, please contact the RealMe Help Desk. From New Zealand:' .
1178
                        ' 0800 664 774 (toll free), from overseas dial +64 9 357 4468 (overseas call charges apply).'
1179
                );
1180
                break;
1181
1182
            default:
1183
                $message = _t(
1184
                    self::class . '.ERROR_GENERAL',
1185
                    'RealMe reported a serious application error. Please try again later. If the problem persists, ' .
1186
                        'please contact the RealMe Help Desk. From New Zealand: 0800 664 774 (toll free), from ' .
1187
                        'overseas dial +64 9 357 4468 (overseas call charges apply).'
1188
                );
1189
                break;
1190
        }
1191
1192
        // Allow message overrides if they exist
1193
        if (array_key_exists($errorCode, $messageOverrides) && !is_null($messageOverrides[$errorCode])) {
1194
            $message = $messageOverrides[$errorCode];
1195
        }
1196
1197
        return $message;
1198
    }
1199
}
1200