Passed
Push — master ( ad7c90...ea847a )
by Tim
07:59
created

Fticks::__construct()   F

Complexity

Conditions 26
Paths 1137

Size

Total Lines 110
Code Lines 74

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 74
c 1
b 0
f 0
dl 0
loc 110
rs 0
cc 26
nc 1137
nop 2

How to fix   Long Method    Complexity   

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
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\fticks\Auth\Process;
6
7
use SAML2\Constants;
8
use SimpleSAML\Assert\Assert;
9
use SimpleSAML\Auth;
10
use SimpleSAML\Configuration;
11
use SimpleSAML\Error\Exception;
12
use SimpleSAML\Logger;
13
use SimpleSAML\Session;
14
use SimpleSAML\Utils;
15
16
/**
17
 * Filter to log F-ticks stats data
18
 * See also:
19
 * - https://wiki.geant.org/display/gn42jra3/F-ticks+standard
20
 * - https://tools.ietf.org/html/draft-johansson-fticks-00
21
 *
22
 * @copyright Copyright (c) 2019, South African Identity Federation
23
 * @package   SimpleSAMLphp
24
 */
25
class Fticks extends Auth\ProcessingFilter
26
{
27
    /** @var string F-ticks version number */
28
    private static string $fticksVersion = '1.0';
29
30
    /** @var string F-ticks federation identifier */
31
    private string $federation;
32
33
    /** @var string A salt to apply when digesting usernames (defaults to config file salt) */
34
    private string $salt;
35
36
    /** @var string The logging backend */
37
    private string $logdest = 'simplesamlphp';
38
39
    /** @var array Backend specific logging config */
40
    private array $logconfig = [];
41
42
    /** @var string|false The username attribute to use */
43
    private $userId = false;
44
45
    /** @var string|false The realm attribute to use */
46
    private $realm = false;
47
48
    /** @var string The hashing algorithm to use */
49
    private string $algorithm = 'sha256';
50
51
    /** @var array|false F-ticks attributes to exclude */
52
    private $exclude = false;
53
54
55
    /**
56
     * Log a message to the desired destination
57
     *
58
     * @param  string $msg message to log
59
     */
60
    private function log(string $msg): void
61
    {
62
        switch ($this->logdest) {
63
            /* local syslog call, avoiding SimpleSAMLphp's wrapping */
64
            case 'local':
65
            case 'syslog':
66
                Assert::keyExists($this->logconfig, 'processname');
67
                Assert::keyExists($this->logconfig, 'facility');
68
69
                openlog($this->logconfig['processname'], LOG_PID, $this->logconfig['facility']);
70
                syslog(array_key_exists('priority', $this->logconfig) ? $this->logconfig['priority'] : LOG_INFO, $msg);
71
                break;
72
73
            /* remote syslog call via UDP */
74
            case 'remote':
75
                Assert::keyExists($this->logconfig, 'processname');
76
                Assert::keyExists($this->logconfig, 'facility');
77
78
                /* assemble a syslog message per RFC 5424 */
79
                $rfc5424_message = sprintf(
80
                    '<%d>',
81
                    ((($this->logconfig['facility'] & 0x03f8) >> 3) * 8) +
82
                    (array_key_exists('priority', $this->logconfig) ? $this->logconfig['priority'] : LOG_INFO)
83
                ); // pri
84
                $rfc5424_message .= '1 '; // ver
85
                $rfc5424_message .= gmdate('Y-m-d\TH:i:s.v\Z '); // timestamp
86
                $rfc5424_message .= gethostname() . ' '; // hostname
87
                $rfc5424_message .= $this->logconfig['processname'] . ' '; // app-name
88
                $rfc5424_message .= posix_getpid() . ' '; // procid
89
                $rfc5424_message .= '- '; // msgid
90
                $rfc5424_message .= '- '; // structured-data
91
                $rfc5424_message .= $msg;
92
                /* send it to the remote host */
93
                $sock = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
94
                socket_sendto(
95
                    $sock,
96
                    $rfc5424_message,
97
                    strlen($rfc5424_message),
98
                    0,
99
                    gethostbyname(array_key_exists('host', $this->logconfig) ? $this->logconfig['host'] : 'localhost'),
100
                    array_key_exists('port', $this->logconfig) ? $this->logconfig['port'] : 514
101
                );
102
                break;
103
104
            case 'errorlog':
105
                error_log($msg);
106
                break;
107
108
            /* mostly for unit testing */
109
            case 'stdout':
110
                echo $msg . "\n";
111
                break;
112
113
            /* SimpleSAMLphp's builtin logging */
114
            case 'simplesamlphp':
115
            default:
116
                Logger::stats($msg);
117
                break;
118
        }
119
    }
120
121
122
    /**
123
     * Generate a PN hash
124
     *
125
     * @param  array $state
126
     * @return string|false $hash
127
     * @throws \SimpleSAML\Error\Exception
128
     */
129
    private function generatePNhash(array &$state)
130
    {
131
        /* get a user id */
132
        if ($this->userId !== false) {
133
            Assert::keyExists($state, 'Attributes');
134
135
            if (array_key_exists($this->userId, $state['Attributes'])) {
0 ignored issues
show
Bug introduced by
It seems like $this->userId can also be of type true; however, parameter $key of array_key_exists() does only seem to accept integer|string, 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

135
            if (array_key_exists(/** @scrutinizer ignore-type */ $this->userId, $state['Attributes'])) {
Loading history...
136
                if (is_array($state['Attributes'][$this->userId])) {
137
                    $uid = $state['Attributes'][$this->userId][0];
138
                } else {
139
                    $uid = $state['Attributes'][$this->userId];
140
                }
141
            }
142
        } elseif (array_key_exists('UserID', $state)) {
143
            $uid = $state['UserID'];
144
        }
145
146
        /* calculate a hash */
147
        if (isset($uid) && is_string($uid)) {
148
            $userdata = $this->federation;
149
            if (array_key_exists('saml:sp:IdP', $state)) {
150
                $userdata .= strlen($state['saml:sp:IdP']) . ':' . $state['saml:sp:IdP'];
151
            } else {
152
                $userdata .= strlen($state['Source']['entityid']) . ':' . $state['Source']['entityid'];
153
            }
154
            $userdata .= strlen($state['Destination']['entityid']) . ':' . $state['Destination']['entityid'];
155
            $userdata .= strlen($uid) . ':' . $uid;
156
            $userdata .= $this->salt;
157
158
            return hash($this->algorithm, $userdata);
159
        }
160
        return false;
161
    }
162
163
164
    /**
165
     * Escape F-ticks values
166
     *
167
     * value = 1*( ALPHA / DIGIT / '_' / '-' / ':' / '.' / ',' / ';')
168
     * ... but add a / for entityIDs
169
     *
170
     * @param  string $value
171
     * @return string $value
172
     */
173
    private function escapeFticks(string $value): string
174
    {
175
        return preg_replace('/[^A-Za-z0-9_\-:.,;\/]+/', '', $value);
176
    }
177
178
179
    /**
180
     * Initialize this filter, parse configuration.
181
     *
182
     * @param  array $config Configuration information about this filter.
183
     * @param  mixed $reserved For future use.
184
     * @throws \SimpleSAML\Error\Exception
185
     */
186
    public function __construct(array $config, $reserved)
187
    {
188
        parent::__construct($config, $reserved);
189
190
        if (array_key_exists('federation', $config)) {
191
            if (is_string($config['federation'])) {
192
                $this->federation = $config['federation'];
193
            } else {
194
                throw new \Exception('Federation identifier must be a string');
195
            }
196
        } else {
197
            throw new \Exception('Federation identifier must be set');
198
        }
199
200
        if (array_key_exists('salt', $config)) {
201
            if (is_string($config['salt'])) {
202
                $this->salt = $config['salt'];
203
            } else {
204
                throw new \Exception('Salt must be a string');
205
            }
206
        } else {
207
            $configUtils = new Utils\Config();
208
            $this->salt = $configUtils->getSecretSalt();
209
        }
210
211
        if (array_key_exists('userId', $config)) {
212
            if (is_string($config['userId'])) {
213
                $this->userId = $config['userId'];
214
            } else {
215
                throw new \Exception('UserId must be a string');
216
            }
217
        }
218
219
        if (array_key_exists('realm', $config)) {
220
            if (is_string($config['realm'])) {
221
                $this->realm = $config['realm'];
222
            } else {
223
                throw new \Exception('realm must be a string');
224
            }
225
        }
226
227
        if (array_key_exists('algorithm', $config)) {
228
            if (
229
                is_string($config['algorithm'])
230
                && in_array($config['algorithm'], hash_algos())
231
            ) {
232
                $this->algorithm = $config['algorithm'];
233
            } else {
234
                throw new \Exception('algorithm must be a hash algorithm listed in hash_algos()');
235
            }
236
        }
237
238
        if (array_key_exists('exclude', $config)) {
239
            if (is_array($config['exclude'])) {
240
                $this->exclude = $config['exclude'];
241
            } elseif (is_string($config['exclude'])) {
242
                $this->exclude = [$config['exclude']];
243
            } else {
244
                throw new \Exception('F-ticks exclude must be an array');
245
            }
246
        }
247
248
        if (array_key_exists('logdest', $config)) {
249
            if (
250
                is_string($config['logdest']) &&
251
                in_array($config['logdest'], ['local', 'syslog', 'remote', 'stdout', 'errorlog', 'simplesamlphp'])
252
            ) {
253
                $this->logdest = $config['logdest'];
254
            } else {
255
                throw new \Exception(
256
                    'F-ticks log destination must be one of [local, remote, stdout, errorlog, simplesamlphp]'
257
                );
258
            }
259
        }
260
261
        /* match SSP config or we risk mucking up the openlog call */
262
        $globalConfig = Configuration::getInstance();
263
        $defaultFacility = $globalConfig->getInteger(
264
            'logging.facility',
265
            defined('LOG_LOCAL5') ? constant('LOG_LOCAL5') : LOG_USER
266
        );
267
        $defaultProcessName = $globalConfig->getString('logging.processname', 'SimpleSAMLphp');
268
        if (array_key_exists('logconfig', $config)) {
269
            if (is_array($config['logconfig'])) {
270
                $this->logconfig = $config['logconfig'];
271
            } else {
272
                throw new \Exception('F-ticks logconfig must be an array');
273
            }
274
        } else {
275
            $this->logconfig['facility'] = $defaultFacility;
276
            $this->logconfig['processname'] = $defaultProcessName;
277
        }
278
        /* warn if we risk mucking up the openlog call (doesn't matter for remote syslog) */
279
        if (in_array($this->logdest, ['local', 'syslog'])) {
280
            if (
281
                array_key_exists('facility', $this->logconfig)
282
                && ($this->logconfig['facility'] !== $defaultFacility)
283
            ) {
284
                Logger::warning(
285
                    'F-ticks syslog facility differs from global config which may cause'
286
                    . ' SimpleSAMLphp\'s logging to behave inconsistently'
287
                );
288
            }
289
            if (
290
                array_key_exists('processname', $this->logconfig)
291
                && ($this->logconfig['processname'] !== $defaultProcessName)
292
            ) {
293
                Logger::warning(
294
                    'F-ticks syslog processname differs from global config which may cause'
295
                    . ' SimpleSAMLphp\'s logging to behave inconsistently'
296
                );
297
            }
298
        }
299
    }
300
301
    /**
302
     * Process this filter
303
     *
304
     * @param  mixed &$state
305
     */
306
    public function process(array &$state): void
307
    {
308
        Assert::keyExists($state, 'Destination');
309
        Assert::keyExists($state['Destination'], 'entityid');
310
        Assert::keyExists($state, 'Source');
311
        Assert::keyExists($state['Source'], 'entityid');
312
313
        $fticks = [];
314
315
        /* AFAIK the AuthProc will only execute if there is prior success */
316
        $fticks['RESULT'] = 'OK';
317
318
        /* SAML IdP entity Id */
319
        if (array_key_exists('saml:sp:IdP', $state)) {
320
            $fticks['AP'] = $state['saml:sp:IdP'];
321
        } else {
322
            $fticks['AP'] = $state['Source']['entityid'];
323
        }
324
325
        /* SAML SP entity Id */
326
        $fticks['RP'] = $state['Destination']['entityid'];
327
328
        /* SAML session id */
329
        $session = Session::getSessionFromRequest();
330
        $fticks['CSI'] = $session->getTrackID();
331
332
        /* Authentication method identifier */
333
        if (
334
            array_key_exists('saml:sp:State', $state)
335
            && array_key_exists('saml:sp:AuthnContext', $state['saml:sp:State'])
336
        ) {
337
            $fticks['AM'] = $state['saml:sp:State']['saml:sp:AuthnContext'];
338
        } elseif (
339
            array_key_exists('SimpleSAML_Auth_State.stage', $state)
340
            && preg_match('/UserPass/', $state['SimpleSAML_Auth_State.stage'])
341
        ) {
342
            /* hack to try identify LDAP et al as Password */
343
            $fticks['AM'] = Constants::AC_PASSWORD;
344
        }
345
346
        /* ePTID */
347
        $pn = $this->generatePNhash($state);
348
        if ($pn !== false) {
349
            $fticks['PN'] = $pn;
350
        }
351
352
        /* timestamp */
353
        if (
354
            array_key_exists('saml:sp:State', $state)
355
            && array_key_exists('saml:AuthnInstant', $state['saml:sp:State'])
356
        ) {
357
            $fticks['TS'] = $state['saml:sp:State']['saml:AuthnInstant'];
358
        } else {
359
            $fticks['TS'] = time();
360
        }
361
362
        /* realm */
363
        if ($this->realm !== false) {
364
            Assert::keyExists($state, 'Attributes');
365
            if (array_key_exists($this->realm, $state['Attributes'])) {
0 ignored issues
show
Bug introduced by
It seems like $this->realm can also be of type true; however, parameter $key of array_key_exists() does only seem to accept integer|string, 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

365
            if (array_key_exists(/** @scrutinizer ignore-type */ $this->realm, $state['Attributes'])) {
Loading history...
366
                if (is_array($state['Attributes'][$this->realm])) {
367
                    $fticks['REALM'] = $state['Attributes'][$this->realm][0];
368
                } else {
369
                    $fticks['REALM'] = $state['Attributes'][$this->realm];
370
                }
371
            }
372
        }
373
374
        /* allow some attributes to be excluded */
375
        if ($this->exclude !== false) {
376
            $fticks = array_filter($fticks, [$this, 'filterExcludedAttributes'], ARRAY_FILTER_USE_KEY);
377
        }
378
379
        /* assemble an F-ticks log string */
380
        $this->log($this->assembleFticksLogString($fticks));
381
    }
382
383
384
    /**
385
     * Callback method to filter excluded attributes
386
     *
387
     * @param string $attr
388
     * @return bool
389
     */
390
    private function filterExcludedAttributes(string $attr): bool
391
    {
392
        return !in_array($attr, $this->exclude);
0 ignored issues
show
Bug introduced by
It seems like $this->exclude can also be of type boolean; however, parameter $haystack of in_array() 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

392
        return !in_array($attr, /** @scrutinizer ignore-type */ $this->exclude);
Loading history...
393
    }
394
395
396
    /**
397
     * Assemble fticks log string
398
     *
399
     * @param array $fticks
400
     * @return string
401
     */
402
    private function assembleFticksLogString(array $fticks): string
403
    {
404
        $attributes = implode(
405
            '#',
406
            array_map(
407
                /**
408
                 * @param  string $k
409
                 * @param  string $v
410
                 * @return string
411
                 */
412
                function ($k, $v) {
413
                    return $k . '=' . $this->escapeFticks(strval($v));
414
                },
415
                array_keys($fticks),
416
                $fticks
417
            )
418
        );
419
420
        return sprintf('F-TICKS/%s/%s#%s#', $this->federation, self::$fticksVersion, $attributes);
421
    }
422
}
423