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

RealMeSetupTask   B

Complexity

Total Complexity 45

Size/Duplication

Total Lines 449
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
dl 0
loc 449
rs 8.8
c 0
b 0
f 0
wmc 45

14 Methods

Rating   Name   Duplication   Size   Complexity  
A message() 0 3 1
A replaceTemplateContents() 0 9 2
A run() 0 28 3
A isReadable() 0 3 1
A validateInputs() 0 38 2
A setService() 0 5 1
A outputMetadataXmlContent() 0 36 2
A getConfigurationTemplateDir() 0 12 3
A validateCertificates() 0 20 4
A validateDirectoryStructure() 0 13 3
A validateRealMeEnvironments() 0 23 3
B validateMetadata() 0 33 7
A validateAuthNContext() 0 18 4
B validateEntityID() 0 73 9

How to fix   Complexity   

Complex Class

Complex classes like RealMeSetupTask often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use RealMeSetupTask, and based on these observations, apply Extract Interface, too.

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 %s environment or DIA ' .
76
                'to complete the integration',
77
                '',
78
                array('env' => $forEnv)
79
            ));
80
        } catch (Exception $e) {
81
            $this->message($e->getMessage() . PHP_EOL);
82
        }
83
    }
84
85
    /**
86
     * @param RealMeService $service
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
                '',
131
                array(
132
                    'numissues' => sizeof($this->errors),
133
                    'issues' => $errorList
134
                )
135
            ));
136
        }
137
138
        $this->message(_t(
139
            self::class . '.VALIDATION_SUCCESS',
140
            'Validation succeeded, continuing with setup...'
141
        ));
142
    }
143
144
    /**
145
     * Outputs metadata template XML to console, so it can be sent to RealMe Operations team
146
     *
147
     * @param string $forEnv The RealMe environment to output metadata content for (e.g. mts, ite, prod).
148
     */
149
    private function outputMetadataXmlContent($forEnv)
150
    {
151
        // Output metadata XML so that it can be sent to RealMe via the agency
152
        $this->message(_t(
153
            self::class . '.OUPUT_PREFIX',
154
            'Metadata XML is listed below for the \'%s\' RealMe environment, this should be sent to the agency so they '
155
                . 'can pass it on to RealMe Operations staff',
156
            $forEnv
157
        ) . PHP_EOL . PHP_EOL);
158
159
        $configDir = $this->getConfigurationTemplateDir();
160
        $templateFile = Controller::join_links($configDir, 'metadata.xml');
161
162
        if (false === $this->isReadable($templateFile)) {
163
            throw new Exception(sprintf("Can't read metadata.xml file at %s", $templateFile));
164
        }
165
166
        $supportContact = $this->service->getMetadataContactSupport();
167
168
        $message = $this->replaceTemplateContents(
169
            $templateFile,
170
            array(
171
                '{{entityID}}' => $this->service->getSPEntityID(),
172
                '{{certificate-data}}' => $this->service->getSPCertContent(),
173
                '{{nameidformat}}' => $this->service->getNameIdFormat(),
174
                '{{acs-url}}' => $this->service->getAssertionConsumerServiceUrlForEnvironment($forEnv),
175
                '{{organisation-name}}' => $this->service->getMetadataOrganisationName(),
176
                '{{organisation-display-name}}' => $this->service->getMetadataOrganisationDisplayName(),
177
                '{{organisation-url}}' => $this->service->getMetadataOrganisationUrl(),
178
                '{{contact-support1-company}}' => $supportContact['company'],
179
                '{{contact-support1-firstnames}}' => $supportContact['firstNames'],
180
                '{{contact-support1-surname}}' => $supportContact['surname'],
181
            )
182
        );
183
184
        $this->message($message);
185
    }
186
187
    /**
188
     * Replace content in a template file with an array of replacements
189
     *
190
     * @param string $templatePath The path to the template file
191
     * @param array|null $replacements An array of '{{variable}}' => 'value' replacements
192
     * @return string The contents, with all {{variables}} replaced
193
     */
194
    private function replaceTemplateContents($templatePath, $replacements = null)
195
    {
196
        $configText = file_get_contents($templatePath);
197
198
        if (true === is_array($replacements)) {
199
            $configText = str_replace(array_keys($replacements), array_values($replacements), $configText);
200
        }
201
202
        return $configText;
203
    }
204
205
    /**
206
     * @return string The full path to RealMe configuration
207
     */
208
    private function getConfigurationTemplateDir()
209
    {
210
        $dir = $this->config()->template_config_dir;
211
        $path = Controller::join_links(BASE_PATH, $dir);
212
213
        if ($dir && false !== $this->isReadable($path)) {
214
            return $path;
215
        }
216
217
        $path = ModuleLoader::inst()->getManifest()->getModule('realme')->getPath();
218
219
        return $path . '/templates/saml-conf';
220
    }
221
222
    /**
223
     * Output a message to the console
224
     * @param string $message
225
     * @return void
226
     */
227
    private function message($message)
228
    {
229
        echo $message . PHP_EOL;
230
    }
231
232
    /**
233
     * Thin wrapper around is_readable(), used mainly so we can test this class completely
234
     *
235
     * @param string $filename The filename or directory to test
236
     * @return bool true if the file/dir is readable, false if not
237
     */
238
    private function isReadable($filename)
239
    {
240
        return is_readable($filename);
241
    }
242
243
    /**
244
     * The entity ID will pass validation, but raise an exception if the format of the service name and privacy realm
245
     * are in the incorrect format.
246
     * The service name and privacy realm need to be under 10 chars eg.
247
     * http://hostname.domain/serviceName/privacyRealm
248
     *
249
     * @param string $forEnv
250
     * @return void
251
     */
252
    private function validateEntityID($forEnv)
253
    {
254
        $entityId = $this->service->getSPEntityID();
255
256
        if (is_null($entityId)) {
257
            $this->errors[] = _t(
258
                self::class . '.ERR_CONFIG_NO_ENTITYID',
259
                'No entityID specified for environment \'{env}\'. Specify this in your YML configuration, see the ' .
260
                    'module documentation for more details',
261
                array('env' => $forEnv)
262
            );
263
        }
264
265
        // make sure the entityID is a valid URL
266
        $entityId = filter_var($entityId, FILTER_VALIDATE_URL);
267
        if ($entityId === false) {
268
            $this->errors[] = _t(
269
                self::class . '.ERR_CONFIG_ENTITYID',
270
                'The Entity ID (\'{entityId}\') must be https, not be \'localhost\', and must contain a valid ' .
271
                    'service name and privacy realm e.g. https://my-realme-integration.govt.nz/p-realm/s-name',
272
                array(
273
                    'entityId' => $entityId
274
                )
275
            );
276
277
            // invalid entity id, no point continuing.
278
            return;
279
        }
280
281
        // check it's not localhost and HTTPS. and make sure we have a host / scheme
282
        $urlParts = parse_url($entityId);
283
        if ($urlParts['host'] === 'localhost' || $urlParts['scheme'] === 'http') {
284
            $this->errors[] = _t(
285
                self::class . '.ERR_CONFIG_ENTITYID',
286
                'The Entity ID (\'{entityId}\') must be https, not be \'localhost\', and must contain a valid ' .
287
                    'service name and privacy realm e.g. https://my-realme-integration.govt.nz/p-realm/s-name',
288
                array(
289
                    'entityId' => $entityId
290
                )
291
            );
292
293
            // if there's this much wrong, we want them to fix it first.
294
            return;
295
        }
296
297
        $path = ltrim($urlParts['path']);
298
        $urlParts = preg_split("/\\//", $path);
299
300
301
        // A valid Entity ID is in the form of "https://www.domain.govt.nz/<privacy-realm>/<service-name>"
302
        // Validate Service Name
303
        $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

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