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

RealMeSetupTask   B

Complexity

Total Complexity 45

Size/Duplication

Total Lines 433
Duplicated Lines 0 %

Importance

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

14 Methods

Rating   Name   Duplication   Size   Complexity  
A validateCertificates() 0 19 4
A validateDirectoryStructure() 0 10 3
A getConfigurationTemplateDir() 0 10 3
A message() 0 3 1
A replaceTemplateContents() 0 9 2
A validateRealMeEnvironments() 0 23 3
B validateMetadata() 0 17 7
A run() 0 28 3
A isReadable() 0 3 1
A validateAuthNContext() 0 20 4
A validateInputs() 0 38 2
C validateEntityID() 0 78 9
A setService() 0 5 1
A outputMetadataXmlContent() 0 36 2

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\RealMe\RealMeService;
9
use SilverStripe\Control\Director;
10
use SilverStripe\Control\Controller;
11
use SilverStripe\Dev\BuildTask;
12
13
/**
14
 * Class RealMeSetupTask
15
 *
16
 * This class is intended to be run by a server administrator once the module is setup and configured via environment
17
 * variables, and YML fragments. The following tasks are done by this build task:
18
 *
19
 * - Check to ensure that the task is being run from the cmdline (not in the browser, it's too sensitive)
20
 * - Check to ensure that the task hasn't already been run, and if it has, fail unless `force=1` is passed to the script
21
 * - Validate all required values have been added in the appropriate place, and provide appropriate errors if not
22
 * - Output metadata XML that must be submitted to RealMe in order to integrate with ITE and Production environments
23
 */
24
class RealMeSetupTask extends BuildTask
25
{
26
    private static $segment = 'RealMeSetupTask';
0 ignored issues
show
introduced by
The private property $segment is not used, and could be removed.
Loading history...
27
28
    private static $dependencies = [
0 ignored issues
show
introduced by
The private property $dependencies is not used, and could be removed.
Loading history...
29
        'Service' => '%$' . RealMeService::class,
30
    ];
31
32
    protected $title = "RealMe Setup Task";
33
34
    protected $description = 'Validates a realme configuration & creates the resources needed to integrate with realme';
35
36
    /**
37
     * @var RealMeService
38
     */
39
    private $service;
40
41
    /**
42
     * A list of validation errors found while validating the realme configuration.
43
     *
44
     * @var string[]
45
     */
46
    private $errors = array();
47
48
    /**
49
     * Run this setup task. See class phpdoc for the full description of what this does
50
     *
51
     * @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...
52
     */
53
    public function run($request)
54
    {
55
        try {
56
            // Ensure we are running on the command-line, and not running in a browser
57
            if (false === Director::is_cli()) {
58
                throw new Exception(_t(
59
                    self::class . '.ERR_NOT_CLI',
60
                    'This task can only be run from the command-line, not in your browser.'
61
                ));
62
            }
63
64
            // Validate all required values exist
65
            $forEnv = $request->getVar('forEnv');
66
67
            // Throws an exception if there was a problem with the config.
68
            $this->validateInputs($forEnv);
69
70
            $this->outputMetadataXmlContent($forEnv);
71
72
            $this->message(PHP_EOL . _t(
73
                self::class . '.BUILD_FINISH',
74
                'RealMe setup complete. Please copy the XML into a file for upload to the %s environment or DIA ' .
75
                'to complete the integration',
76
                '',
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
     */
87
    public function setService($service)
88
    {
89
        $this->service = $service;
90
91
        return $this;
92
    }
93
94
    /**
95
     * Validate all inputs to this setup script. Ensures that all required values are available, where-ever they need to
96
     * be loaded from (environment variables, Config API, or directly passed to this script via the cmd-line)
97
     *
98
     * @param string $forEnv The environment that we want to output content for (mts, ite, or prod)
99
     *
100
     * @throws Exception if there were errors with the request or setup format.
101
     */
102
    private function validateInputs($forEnv)
103
    {
104
        // Ensure that 'forEnv=' is specified on the cli, and ensure that it matches a RealMe environment
105
        $this->validateRealMeEnvironments($forEnv);
106
107
        // Ensure we have the necessary directory structures, and their visibility
108
        $this->validateDirectoryStructure();
109
110
        // Ensure we have the certificates in the correct places.
111
        $this->validateCertificates();
112
113
        // Ensure the entityID is valid, and the privacy realm and service name are correct
114
        $this->validateEntityID($forEnv);
115
116
        // Make sure we have an authncontext for each environment.
117
        $this->validateAuthNContext();
118
119
        // Ensure data required for metadata XML output exists
120
        $this->validateMetadata();
121
122
        // Output validation errors, if any are found
123
        if (sizeof($this->errors) > 0) {
124
            $errorList = PHP_EOL . ' - ' . join(PHP_EOL . ' - ', $this->errors);
125
126
            throw new Exception(_t(
127
                self::class . '.ERR_VALIDATION',
128
                'There were {numissues} issue(s) found during validation that must be fixed prior to setup: {issues}',
129
                '',
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 \'%s\' RealMe environment, this should be sent to the agency so they '
154
                . 'can pass it on to RealMe Operations staff',
155
            $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
        return Controller::join_links(BASE_PATH, REALME_MODULE_PATH . '/templates/saml-conf');
217
    }
218
219
    /**
220
     * Output a message to the console
221
     * @param string $message
222
     * @return void
223
     */
224
    private function message($message)
225
    {
226
        echo $message . PHP_EOL;
227
    }
228
229
    /**
230
     * Thin wrapper around is_readable(), used mainly so we can test this class completely
231
     *
232
     * @param string $filename The filename or directory to test
233
     * @return bool true if the file/dir is readable, false if not
234
     */
235
    private function isReadable($filename)
236
    {
237
        return is_readable($filename);
238
    }
239
240
    /**
241
     * The entity ID will pass validation, but raise an exception if the format of the service name and privacy realm
242
     * are in the incorrect format.
243
     * The service name and privacy realm need to be under 10 chars eg.
244
     * http://hostname.domain/serviceName/privacyRealm
245
     *
246
     * @param string $forEnv
247
     * @return void
248
     */
249
    private function validateEntityID($forEnv)
250
    {
251
        $entityId = $this->service->getSPEntityID();
252
253
        if (is_null($entityId)) {
254
            $this->errors[] = _t(
255
                self::class . '.ERR_CONFIG_NO_ENTITYID',
256
                'No entityID specified for environment \'{env}\'. Specify this in your YML configuration, see the' .
257
                ' module documentation for more details',
258
                '',
259
                array('env' => $forEnv)
260
            );
261
        }
262
263
        // make sure the entityID is a valid URL
264
        $entityId = filter_var($entityId, FILTER_VALIDATE_URL);
265
        if ($entityId === false) {
266
            $this->errors[] = _t(
267
                self::class . '.ERR_CONFIG_ENTITYID',
268
                'The Entity ID (\'{entityId}\') must be https, not be \'localhost\', and must contain a valid ' .
269
                'service name and privacy realm e.g. https://my-realme-integration.govt.nz/p-realm/s-name',
270
                '',
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
                '',
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
                '',
310
                array(
311
                    'serviceName' => $serviceName,
312
                    'entityId' => $entityId
313
                )
314
            );
315
        }
316
317
        // Validate Privacy Realm
318
        $privacyRealm = array_pop($urlParts);
319
        if (null === $privacyRealm || 0 === mb_strlen($privacyRealm)) {
320
            $this->errors[] = _t(
321
                self::class . '.ERR_CONFIG_ENTITYID_PRIVACY_REALM',
322
                'The privacy realm \'{privacyRealm}\' must not be blank for entityID \'{entityId}\'',
323
                '',
324
                array(
325
                    'privacyRealm' => $privacyRealm,
326
                    'entityId' => $entityId
327
                )
328
            );
329
        }
330
    }
331
332
    /**
333
     * Ensure we have an authncontext (how secure auth we require for each environment)
334
     *
335
     * e.g. urn:nzl:govt:ict:stds:authn:deployment:GLS:SAML:2.0:ac:classes:LowStrength
336
     */
337
    private function validateAuthNContext()
338
    {
339
        foreach ($this->service->getAllowedRealMeEnvironments() as $env) {
340
            $context = $this->service->getAuthnContextForEnvironment($env);
341
            if (is_null($context)) {
342
                $this->errors[] = _t(
343
                    self::class . '.ERR_CONFIG_NO_AUTHNCONTEXT',
344
                    'No AuthnContext specified for environment \'{env}\'. Specify this in your YML configuration, ' .
345
                    'see the module documentation for more details',
346
                    '',
347
                    array('env' => $env)
348
                );
349
            }
350
351
            if (!in_array($context, $this->service->getAllowedAuthNContextList())) {
352
                $this->errors[] = _t(
353
                    self::class . '.ERR_CONFIG_INVALID_AUTHNCONTEXT',
354
                    'The AuthnContext specified for environment \'{env}\' is invalid, please check your configuration',
355
                    '',
356
                    array('env' => $env)
357
                );
358
            }
359
        }
360
    }
361
362
    /**
363
     * Ensure's the environment we're building the setup for exists.
364
     *
365
     * @param string $forEnv The environment that we're going to configure with this run.
366
     */
367
    private function validateRealMeEnvironments($forEnv)
368
    {
369
        $allowedEnvs = $this->service->getAllowedRealMeEnvironments();
370
        if (0 === mb_strlen($forEnv)) {
371
            $this->errors[] = _t(
372
                self::class . '.ERR_ENV_NOT_SPECIFIED',
373
                '',
374
                '',
375
                array(
376
                    'allowedEnvs' => join(', ', $allowedEnvs)
377
                )
378
            );
379
            return;
380
        }
381
382
        if (false === in_array($forEnv, $allowedEnvs)) {
383
            $this->errors[] = _t(
384
                self::class . '.ERR_ENV_NOT_ALLOWED',
385
                '',
386
                '',
387
                array(
388
                    'env' => $forEnv,
389
                    'allowedEnvs' => join(', ', $allowedEnvs)
390
                )
391
            );
392
        }
393
    }
394
395
    /**
396
     * Ensures that the directory structure is correct and the necessary directories are writable.
397
     */
398
    private function validateDirectoryStructure()
399
    {
400
        if (is_null($this->service->getCertDir())) {
0 ignored issues
show
introduced by
The condition is_null($this->service->getCertDir()) is always false.
Loading history...
401
            $this->errors[] = _t(self::class . '.ERR_CERT_DIR_MISSING');
402
        } elseif (!$this->isReadable($this->service->getCertDir())) {
403
            $this->errors[] = _t(
404
                self::class . '.ERR_CERT_DIR_NOT_READABLE',
405
                '',
406
                '',
407
                array('dir' => $this->service->getCertDir())
408
            );
409
        }
410
    }
411
412
    /**
413
     * Ensures that the required metadata is filled out correctly in the realme configuration.
414
     */
415
    private function validateMetadata()
416
    {
417
        if (is_null($this->service->getMetadataOrganisationName())) {
418
            $this->errors[] = _t(self::class . '.ERR_CONFIG_NO_ORGANISATION_NAME');
419
        }
420
421
        if (is_null($this->service->getMetadataOrganisationDisplayName())) {
422
            $this->errors[] = _t(self::class . '.ERR_CONFIG_NO_ORGANISATION_DISPLAY_NAME');
423
        }
424
425
        if (is_null($this->service->getMetadataOrganisationUrl())) {
426
            $this->errors[] = _t(self::class . '.ERR_CONFIG_NO_ORGANISATION_URL');
427
        }
428
429
        $contact = $this->service->getMetadataContactSupport();
430
        if (is_null($contact['company']) || is_null($contact['firstNames']) || is_null($contact['surname'])) {
0 ignored issues
show
introduced by
The condition is_null($contact['surname']) is always false.
Loading history...
introduced by
The condition is_null($contact['firstNames']) is always false.
Loading history...
431
            $this->errors[] = _t(self::class . '.ERR_CONFIG_NO_SUPPORT_CONTACT');
432
        }
433
    }
434
435
    /**
436
     * Ensures the certificates are readable and that the service can sign and unencrypt using them
437
     */
438
    private function validateCertificates()
439
    {
440
        $signingCertFile = $this->service->getSigningCertPath();
441
        if (is_null($signingCertFile) || !$this->isReadable($signingCertFile)) {
442
            $this->errors[] = _t(
443
                self::class . '.ERR_CERT_NO_SIGNING_CERT',
444
                '',
445
                '',
446
                array(
447
                    'const' => 'REALME_SIGNING_CERT_FILENAME'
448
                )
449
            );
450
        } elseif (is_null($this->service->getSPCertContent())) {
451
            // Signing cert exists, but doesn't include BEGIN/END CERTIFICATE lines, or doesn't contain the cert
452
            $this->errors[] = _t(
453
                self::class . '.ERR_CERT_SIGNING_CERT_CONTENT',
454
                '',
455
                '',
456
                array('file' => $this->service->getSigningCertPath())
457
            );
458
        }
459
    }
460
}
461