Issues (37)

src/Task/RealMeSetupTask.php (6 issues)

1
<?php
2
3
namespace SilverStripe\RealMe\Task;
4
5
use Exception;
6
7
use SilverStripe\Control\HTTPRequest;
8
use SilverStripe\Core\Injector\Injector;
9
use SilverStripe\Core\Manifest\ModuleLoader;
10
use SilverStripe\RealMe\RealMeService;
11
use SilverStripe\Control\Director;
12
use SilverStripe\Control\Controller;
13
use SilverStripe\Dev\BuildTask;
14
15
/**
16
 * Class RealMeSetupTask
17
 *
18
 * This class is intended to be run by a server administrator once the module is setup and configured via environment
19
 * variables, and YML fragments. The following tasks are done by this build task:
20
 *
21
 * - Check to ensure that the task is being run from the cmdline (not in the browser, it's too sensitive)
22
 * - Check to ensure that the task hasn't already been run, and if it has, fail unless `force=1` is passed to the script
23
 * - Validate all required values have been added in the appropriate place, and provide appropriate errors if not
24
 * - Output metadata XML that must be submitted to RealMe in order to integrate with ITE and Production environments
25
 */
26
class RealMeSetupTask extends BuildTask
27
{
28
    private static $segment = 'RealMeSetupTask';
0 ignored issues
show
The private property $segment is not used, and could be removed.
Loading history...
29
30
    private static $dependencies = [
0 ignored issues
show
The private property $dependencies is not used, and could be removed.
Loading history...
31
        'Service' => '%$' . RealMeService::class,
32
    ];
33
34
    protected $title = "RealMe Setup Task";
35
36
    protected $description = 'Validates a realme configuration & creates the resources needed to integrate with realme';
37
38
    /**
39
     * @var RealMeService
40
     */
41
    private $service;
42
43
    /**
44
     * A list of validation errors found while validating the realme configuration.
45
     *
46
     * @var string[]
47
     */
48
    private $errors = array();
49
50
    /**
51
     * Run this setup task. See class phpdoc for the full description of what this does
52
     *
53
     * @param HTTPRequest $request
54
     */
55
    public function run($request)
56
    {
57
        try {
58
            // Ensure we are running on the command-line, and not running in a browser
59
            if (false === Director::is_cli()) {
60
                throw new Exception(_t(
61
                    self::class . '.ERR_NOT_CLI',
62
                    'This task can only be run from the command-line, not in your browser.'
63
                ));
64
            }
65
66
            // Validate all required values exist
67
            $forEnv = $request->getVar('forEnv');
68
69
            // Throws an exception if there was a problem with the config.
70
            $this->validateInputs($forEnv);
71
72
            $this->outputMetadataXmlContent($forEnv);
73
74
            $this->message(PHP_EOL . _t(
75
                self::class . '.BUILD_FINISH',
76
                'RealMe setup complete. Please copy the XML into a file for upload to the {env} environment or DIA ' .
77
                'to complete the integration',
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
     * @return $this
88
     */
89
    public function setService($service)
90
    {
91
        $this->service = $service;
92
93
        return $this;
94
    }
95
96
    /**
97
     * Validate all inputs to this setup script. Ensures that all required values are available, where-ever they need to
98
     * be loaded from (environment variables, Config API, or directly passed to this script via the cmd-line)
99
     *
100
     * @param string $forEnv The environment that we want to output content for (mts, ite, or prod)
101
     *
102
     * @throws Exception if there were errors with the request or setup format.
103
     */
104
    private function validateInputs($forEnv)
105
    {
106
        // Ensure that 'forEnv=' is specified on the cli, and ensure that it matches a RealMe environment
107
        $this->validateRealMeEnvironments($forEnv);
108
109
        // Ensure we have the necessary directory structures, and their visibility
110
        $this->validateDirectoryStructure();
111
112
        // Ensure we have the certificates in the correct places.
113
        $this->validateCertificates();
114
115
        // Ensure the entityID is valid, and the privacy realm and service name are correct
116
        $this->validateEntityID($forEnv);
117
118
        // Make sure we have an authncontext for each environment.
119
        $this->validateAuthNContext();
120
121
        // Ensure data required for metadata XML output exists
122
        $this->validateMetadata();
123
124
        // Output validation errors, if any are found
125
        if (sizeof($this->errors) > 0) {
126
            $errorList = PHP_EOL . ' - ' . join(PHP_EOL . ' - ', $this->errors);
127
128
            throw new Exception(_t(
129
                self::class . '.ERR_VALIDATION',
130
                'There were {numissues} issue(s) found during validation that must be fixed prior to setup: {issues}',
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 \'{env}\' RealMe environment, this should be sent to the agency so ' .
155
                'they can pass it on to RealMe Operations staff',
156
            ['env' => $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
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. vendor/bin/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
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
The condition is_null($contact['surname']) is always false.
Loading history...
The condition is_null($contact['firstNames']) 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