Completed
Pull Request — master (#23)
by
unknown
02:36
created

RealMeSetupTask::validateEntityID()   B

Complexity

Conditions 9
Paths 12

Size

Total Lines 73

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
nc 12
nop 1
dl 0
loc 73
rs 7.0335
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\Task;
4
5
use Exception;
6
7
use SilverStripe\Core\Injector\Injector;
8
use SilverStripe\Core\Manifest\ModuleLoader;
9
use SilverStripe\RealMe\RealMeService;
10
use SilverStripe\Control\Director;
11
use SilverStripe\Control\Controller;
12
use SilverStripe\Dev\BuildTask;
13
14
/**
15
 * Class RealMeSetupTask
16
 *
17
 * This class is intended to be run by a server administrator once the module is setup and configured via environment
18
 * variables, and YML fragments. The following tasks are done by this build task:
19
 *
20
 * - Check to ensure that the task is being run from the cmdline (not in the browser, it's too sensitive)
21
 * - Check to ensure that the task hasn't already been run, and if it has, fail unless `force=1` is passed to the script
22
 * - Validate all required values have been added in the appropriate place, and provide appropriate errors if not
23
 * - Output metadata XML that must be submitted to RealMe in order to integrate with ITE and Production environments
24
 */
25
class RealMeSetupTask extends BuildTask
26
{
27
    private static $segment = 'RealMeSetupTask';
0 ignored issues
show
introduced by
The private property $segment is not used, and could be removed.
Loading history...
28
29
    private static $dependencies = [
0 ignored issues
show
introduced by
The private property $dependencies is not used, and could be removed.
Loading history...
30
        'Service' => '%$' . RealMeService::class,
31
    ];
32
33
    protected $title = "RealMe Setup Task";
34
35
    protected $description = 'Validates a realme configuration & creates the resources needed to integrate with realme';
36
37
    /**
38
     * @var RealMeService
39
     */
40
    private $service;
41
42
    /**
43
     * A list of validation errors found while validating the realme configuration.
44
     *
45
     * @var string[]
46
     */
47
    private $errors = array();
48
49
    /**
50
     * Run this setup task. See class phpdoc for the full description of what this does
51
     *
52
     * @param SS_HTTPRequest $request
0 ignored issues
show
Bug introduced by
The type SilverStripe\RealMe\Task\SS_HTTPRequest was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
53
     */
54
    public function run($request)
55
    {
56
        try {
57
            // Ensure we are running on the command-line, and not running in a browser
58
            if (false === Director::is_cli()) {
59
                throw new Exception(_t(
60
                    self::class . '.ERR_NOT_CLI',
61
                    'This task can only be run from the command-line, not in your browser.'
62
                ));
63
            }
64
65
            // Validate all required values exist
66
            $forEnv = $request->getVar('forEnv');
67
68
            // Throws an exception if there was a problem with the config.
69
            $this->validateInputs($forEnv);
70
71
            $this->outputMetadataXmlContent($forEnv);
72
73
            $this->message(PHP_EOL . _t(
74
                self::class . '.BUILD_FINISH',
75
                'RealMe setup complete. Please copy the XML into a file for upload to the {env} environment or DIA ' .
76
                'to complete the integration',
77
                array('env' => $forEnv)
78
            ));
79
        } catch (Exception $e) {
80
            $this->message($e->getMessage() . PHP_EOL);
81
        }
82
    }
83
84
    /**
85
     * @param RealMeService $service
86
     * @return $this
87
     */
88
    public function setService($service)
89
    {
90
        $this->service = $service;
91
92
        return $this;
93
    }
94
95
    /**
96
     * Validate all inputs to this setup script. Ensures that all required values are available, where-ever they need to
97
     * be loaded from (environment variables, Config API, or directly passed to this script via the cmd-line)
98
     *
99
     * @param string $forEnv The environment that we want to output content for (mts, ite, or prod)
100
     *
101
     * @throws Exception if there were errors with the request or setup format.
102
     */
103
    private function validateInputs($forEnv)
104
    {
105
        // Ensure that 'forEnv=' is specified on the cli, and ensure that it matches a RealMe environment
106
        $this->validateRealMeEnvironments($forEnv);
107
108
        // Ensure we have the necessary directory structures, and their visibility
109
        $this->validateDirectoryStructure();
110
111
        // Ensure we have the certificates in the correct places.
112
        $this->validateCertificates();
113
114
        // Ensure the entityID is valid, and the privacy realm and service name are correct
115
        $this->validateEntityID($forEnv);
116
117
        // Make sure we have an authncontext for each environment.
118
        $this->validateAuthNContext();
119
120
        // Ensure data required for metadata XML output exists
121
        $this->validateMetadata();
122
123
        // Output validation errors, if any are found
124
        if (sizeof($this->errors) > 0) {
125
            $errorList = PHP_EOL . ' - ' . join(PHP_EOL . ' - ', $this->errors);
126
127
            throw new Exception(_t(
128
                self::class . '.ERR_VALIDATION',
129
                'There were {numissues} issue(s) found during validation that must be fixed prior to setup: {issues}',
130
                array(
131
                    'numissues' => sizeof($this->errors),
132
                    'issues' => $errorList
133
                )
134
            ));
135
        }
136
137
        $this->message(_t(
138
            self::class . '.VALIDATION_SUCCESS',
139
            'Validation succeeded, continuing with setup...'
140
        ));
141
    }
142
143
    /**
144
     * Outputs metadata template XML to console, so it can be sent to RealMe Operations team
145
     *
146
     * @param string $forEnv The RealMe environment to output metadata content for (e.g. mts, ite, prod).
147
     */
148
    private function outputMetadataXmlContent($forEnv)
149
    {
150
        // Output metadata XML so that it can be sent to RealMe via the agency
151
        $this->message(_t(
152
            self::class . '.OUPUT_PREFIX',
153
            'Metadata XML is listed below for the \'{env}\' RealMe environment, this should be sent to the agency so ' .
154
                'they can pass it on to RealMe Operations staff',
155
            ['env' => $forEnv]
156
        ) . PHP_EOL . PHP_EOL);
157
158
        $configDir = $this->getConfigurationTemplateDir();
159
        $templateFile = Controller::join_links($configDir, 'metadata.xml');
160
161
        if (false === $this->isReadable($templateFile)) {
162
            throw new Exception(sprintf("Can't read metadata.xml file at %s", $templateFile));
163
        }
164
165
        $supportContact = $this->service->getMetadataContactSupport();
166
167
        $message = $this->replaceTemplateContents(
168
            $templateFile,
169
            array(
170
                '{{entityID}}' => $this->service->getSPEntityID(),
171
                '{{certificate-data}}' => $this->service->getSPCertContent(),
172
                '{{nameidformat}}' => $this->service->getNameIdFormat(),
173
                '{{acs-url}}' => $this->service->getAssertionConsumerServiceUrlForEnvironment($forEnv),
174
                '{{organisation-name}}' => $this->service->getMetadataOrganisationName(),
175
                '{{organisation-display-name}}' => $this->service->getMetadataOrganisationDisplayName(),
176
                '{{organisation-url}}' => $this->service->getMetadataOrganisationUrl(),
177
                '{{contact-support1-company}}' => $supportContact['company'],
178
                '{{contact-support1-firstnames}}' => $supportContact['firstNames'],
179
                '{{contact-support1-surname}}' => $supportContact['surname'],
180
            )
181
        );
182
183
        $this->message($message);
184
    }
185
186
    /**
187
     * Replace content in a template file with an array of replacements
188
     *
189
     * @param string $templatePath The path to the template file
190
     * @param array|null $replacements An array of '{{variable}}' => 'value' replacements
191
     * @return string The contents, with all {{variables}} replaced
192
     */
193
    private function replaceTemplateContents($templatePath, $replacements = null)
194
    {
195
        $configText = file_get_contents($templatePath);
196
197
        if (true === is_array($replacements)) {
198
            $configText = str_replace(array_keys($replacements), array_values($replacements), $configText);
199
        }
200
201
        return $configText;
202
    }
203
204
    /**
205
     * @return string The full path to RealMe configuration
206
     */
207
    private function getConfigurationTemplateDir()
208
    {
209
        $dir = $this->config()->template_config_dir;
210
        $path = Controller::join_links(BASE_PATH, $dir);
211
212
        if ($dir && false !== $this->isReadable($path)) {
213
            return $path;
214
        }
215
216
        $path = ModuleLoader::inst()->getManifest()->getModule('realme')->getPath();
217
218
        return $path . '/templates/saml-conf';
219
    }
220
221
    /**
222
     * Output a message to the console
223
     * @param string $message
224
     * @return void
225
     */
226
    private function message($message)
227
    {
228
        echo $message . PHP_EOL;
229
    }
230
231
    /**
232
     * Thin wrapper around is_readable(), used mainly so we can test this class completely
233
     *
234
     * @param string $filename The filename or directory to test
235
     * @return bool true if the file/dir is readable, false if not
236
     */
237
    private function isReadable($filename)
238
    {
239
        return is_readable($filename);
240
    }
241
242
    /**
243
     * The entity ID will pass validation, but raise an exception if the format of the service name and privacy realm
244
     * are in the incorrect format.
245
     * The service name and privacy realm need to be under 10 chars eg.
246
     * http://hostname.domain/serviceName/privacyRealm
247
     *
248
     * @param string $forEnv
249
     * @return void
250
     */
251
    private function validateEntityID($forEnv)
252
    {
253
        $entityId = $this->service->getSPEntityID();
254
255
        if (is_null($entityId)) {
256
            $this->errors[] = _t(
257
                self::class . '.ERR_CONFIG_NO_ENTITYID',
258
                'No entityID specified for environment \'{env}\'. Specify this in your YML configuration, see the ' .
259
                    'module documentation for more details',
260
                array('env' => $forEnv)
261
            );
262
        }
263
264
        // make sure the entityID is a valid URL
265
        $entityId = filter_var($entityId, FILTER_VALIDATE_URL);
266
        if ($entityId === false) {
267
            $this->errors[] = _t(
268
                self::class . '.ERR_CONFIG_ENTITYID',
269
                'The Entity ID (\'{entityId}\') must be https, not be \'localhost\', and must contain a valid ' .
270
                    'service name and privacy realm e.g. https://my-realme-integration.govt.nz/p-realm/s-name',
271
                array(
272
                    'entityId' => $entityId
273
                )
274
            );
275
276
            // invalid entity id, no point continuing.
277
            return;
278
        }
279
280
        // check it's not localhost and HTTPS. and make sure we have a host / scheme
281
        $urlParts = parse_url($entityId);
282
        if ($urlParts['host'] === 'localhost' || $urlParts['scheme'] === 'http') {
283
            $this->errors[] = _t(
284
                self::class . '.ERR_CONFIG_ENTITYID',
285
                'The Entity ID (\'{entityId}\') must be https, not be \'localhost\', and must contain a valid ' .
286
                    'service name and privacy realm e.g. https://my-realme-integration.govt.nz/p-realm/s-name',
287
                array(
288
                    'entityId' => $entityId
289
                )
290
            );
291
292
            // if there's this much wrong, we want them to fix it first.
293
            return;
294
        }
295
296
        $path = ltrim($urlParts['path']);
297
        $urlParts = preg_split("/\\//", $path);
298
299
300
        // A valid Entity ID is in the form of "https://www.domain.govt.nz/<privacy-realm>/<service-name>"
301
        // Validate Service Name
302
        $serviceName = array_pop($urlParts);
0 ignored issues
show
Bug introduced by
It seems like $urlParts can also be of type false; however, parameter $array of array_pop() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

302
        $serviceName = array_pop(/** @scrutinizer ignore-type */ $urlParts);
Loading history...
303
        if (mb_strlen($serviceName) > 20 || 0 === mb_strlen($serviceName)) {
304
            $this->errors[] = _t(
305
                self::class . '.ERR_CONFIG_ENTITYID_SERVICE_NAME',
306
                'The service name \'{serviceName}\' must be a maximum of 20 characters and not blank for entityID ' .
307
                    '\'{entityId}\'',
308
                array(
309
                    'serviceName' => $serviceName,
310
                    'entityId' => $entityId
311
                )
312
            );
313
        }
314
315
        // Validate Privacy Realm
316
        $privacyRealm = array_pop($urlParts);
317
        if (null === $privacyRealm || 0 === mb_strlen($privacyRealm)) {
318
            $this->errors[] = _t(
319
                self::class . '.ERR_CONFIG_ENTITYID_PRIVACY_REALM',
320
                'The privacy realm \'{privacyRealm}\' must not be blank for entityID \'{entityId}\'',
321
                array(
322
                    'privacyRealm' => $privacyRealm,
323
                    'entityId' => $entityId
324
                )
325
            );
326
        }
327
    }
328
329
    /**
330
     * Ensure we have an authncontext (how secure auth we require for each environment)
331
     *
332
     * e.g. urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:LowStrength
333
     */
334
    private function validateAuthNContext()
335
    {
336
        foreach ($this->service->getAllowedRealMeEnvironments() as $env) {
337
            $context = $this->service->getAuthnContextForEnvironment($env);
338
            if (is_null($context)) {
339
                $this->errors[] = _t(
340
                    self::class . '.ERR_CONFIG_NO_AUTHNCONTEXT',
341
                    'No AuthnContext specified for environment \'{env}\'. Specify this in your YML configuration, ' .
342
                        'see the module documentation for more details',
343
                    array('env' => $env)
344
                );
345
            }
346
347
            if (!in_array($context, $this->service->getAllowedAuthNContextList())) {
348
                $this->errors[] = _t(
349
                    self::class . '.ERR_CONFIG_INVALID_AUTHNCONTEXT',
350
                    'The AuthnContext specified for environment \'{env}\' is invalid, please check your configuration',
351
                    array('env' => $env)
352
                );
353
            }
354
        }
355
    }
356
357
    /**
358
     * Ensure's the environment we're building the setup for exists.
359
     *
360
     * @param string $forEnv The environment that we're going to configure with this run.
361
     */
362
    private function validateRealMeEnvironments($forEnv)
363
    {
364
        $allowedEnvs = $this->service->getAllowedRealMeEnvironments();
365
        if (0 === mb_strlen($forEnv)) {
366
            $this->errors[] = _t(
367
                self::class . '.ERR_ENV_NOT_SPECIFIED',
368
                'The RealMe environment was not specified on the cli It must be one of: {allowedEnvs} ' .
369
                    'e.g. vendor/bin/sake dev/tasks/RealMeSetupTask forEnv=mts',
370
                array(
371
                    'allowedEnvs' => join(', ', $allowedEnvs)
372
                )
373
            );
374
            return;
375
        }
376
377
        if (false === in_array($forEnv, $allowedEnvs)) {
378
            $this->errors[] = _t(
379
                self::class . '.ERR_ENV_NOT_ALLOWED',
380
                'The RealMe environment specified on the cli (\'{env}\') is not allowed. ' .
381
                    'It must be one of: {allowedEnvs}',
382
                array(
383
                    'env' => $forEnv,
384
                    'allowedEnvs' => join(', ', $allowedEnvs)
385
                )
386
            );
387
        }
388
    }
389
390
    /**
391
     * Ensures that the directory structure is correct and the necessary directories are writable.
392
     */
393
    private function validateDirectoryStructure()
394
    {
395
        if (is_null($this->service->getCertDir())) {
0 ignored issues
show
introduced by
The condition is_null($this->service->getCertDir()) is always false.
Loading history...
396
            $this->errors[] = _t(
397
                self::class . '.ERR_CERT_DIR_MISSING',
398
                'No certificate dir is specified. Define the REALME_CERT_DIR environment variable in your .env file'
399
            );
400
        } elseif (!$this->isReadable($this->service->getCertDir())) {
401
            $this->errors[] = _t(
402
                self::class . '.ERR_CERT_DIR_NOT_READABLE',
403
                'Certificate dir specified (\'{dir}\') must be created and be readable. Ensure permissions are set ' .
404
                    'correctly and the directory is absolute',
405
                array('dir' => $this->service->getCertDir())
406
            );
407
        }
408
    }
409
410
    /**
411
     * Ensures that the required metadata is filled out correctly in the realme configuration.
412
     */
413
    private function validateMetadata()
414
    {
415
        if (is_null($this->service->getMetadataOrganisationName())) {
416
            $this->errors[] = _t(
417
                self::class . '.ERR_CONFIG_NO_ORGANISATION_NAME',
418
                'No organisation name is specified in YML configuration. Ensure the \'metadata_organisation_name\' ' .
419
                    'value is defined in your YML configuration'
420
            );
421
        }
422
423
        if (is_null($this->service->getMetadataOrganisationDisplayName())) {
424
            $this->errors[] = _t(
425
                self::class . '.ERR_CONFIG_NO_ORGANISATION_DISPLAY_NAME',
426
                'No organisation display name is specified in YML configuration. Ensure the ' .
427
                    '\'metadata_organisation_display_name\' value is defined in your YML configuration'
428
            );
429
        }
430
431
        if (is_null($this->service->getMetadataOrganisationUrl())) {
432
            $this->errors[] = _t(
433
                self::class . '.ERR_CONFIG_NO_ORGANISATION_URL',
434
                'No organisation URL is specified in YML configuration. Ensure the \'metadata_organisation_url\' ' .
435
                    'value is defined in your YML configuration'
436
            );
437
        }
438
439
        $contact = $this->service->getMetadataContactSupport();
440
        if (is_null($contact['company']) || is_null($contact['firstNames']) || is_null($contact['surname'])) {
0 ignored issues
show
introduced by
The condition is_null($contact['firstNames']) is always false.
Loading history...
introduced by
The condition is_null($contact['surname']) is always false.
Loading history...
441
            $this->errors[] = _t(
442
                self::class . '.ERR_CONFIG_NO_SUPPORT_CONTACT',
443
                'Support contact detail is missing from YML configuration. Ensure the following values are defined ' .
444
                    'in the YML configuration: metadata_contact_support_company, metadata_contact_support_firstnames,' .
445
                    ' metadata_contact_support_surname'
446
            );
447
        }
448
    }
449
450
    /**
451
     * Ensures the certificates are readable and that the service can sign and unencrypt using them
452
     */
453
    private function validateCertificates()
454
    {
455
        $signingCertFile = $this->service->getSigningCertPath();
456
        if (is_null($signingCertFile) || !$this->isReadable($signingCertFile)) {
457
            $this->errors[] = _t(
458
                self::class . '.ERR_CERT_NO_SIGNING_CERT',
459
                'No SAML signing PEM certificate defined, or the file can\'t be read. Define the {const} environment ' .
460
                    'variable in your .env file, and ensure the file exists in the certificate directory',
461
                array(
462
                    'const' => 'REALME_SIGNING_CERT_FILENAME'
463
                )
464
            );
465
        } elseif (is_null($this->service->getSPCertContent())) {
466
            // Signing cert exists, but doesn't include BEGIN/END CERTIFICATE lines, or doesn't contain the cert
467
            $this->errors[] = _t(
468
                self::class . '.ERR_CERT_SIGNING_CERT_CONTENT',
469
                'The file specified for the signing certificate ({file}) does not contain a valid certificate ' .
470
                    '(beginning with -----BEGIN CERTIFICATE-----). Check this file to ensure it contains the ' .
471
                    'certificate and private key',
472
                array('file' => $this->service->getSigningCertPath())
473
            );
474
        }
475
    }
476
}
477