Passed
Push — master ( 015717...d262e4 )
by Tim
02:16
created

Fticks   F

Complexity

Total Complexity 62

Size/Duplication

Total Lines 410
Duplicated Lines 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
wmc 62
eloc 195
c 3
b 0
f 0
dl 0
loc 410
rs 3.44

8 Methods

Rating   Name   Duplication   Size   Complexity  
B log() 0 58 11
B generatePNhash() 0 32 8
A escapeFticks() 0 3 1
A assembleFticksLogString() 0 19 1
C process() 0 75 13
F __construct() 0 96 22
A warnRiskyLogSettings() 0 18 5
A filterExcludedAttributes() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like Fticks 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 Fticks, and based on these observations, apply Extract Interface, too.

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

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

404
            if (array_key_exists(/** @scrutinizer ignore-type */ $this->realm, $state['Attributes'])) {
Loading history...
405
                if (is_array($state['Attributes'][$this->realm])) {
406
                    $fticks['REALM'] = $state['Attributes'][$this->realm][0];
407
                } else {
408
                    $fticks['REALM'] = $state['Attributes'][$this->realm];
409
                }
410
            }
411
        }
412
413
        /* allow some attributes to be excluded */
414
        if ($this->exclude !== false) {
415
            $fticks = array_filter($fticks, [$this, 'filterExcludedAttributes'], ARRAY_FILTER_USE_KEY);
416
        }
417
418
        /* assemble an F-ticks log string */
419
        $this->log($this->assembleFticksLogString($fticks));
420
    }
421
422
423
    /**
424
     * Callback method to filter excluded attributes
425
     *
426
     * @param string $attr
427
     * @return bool
428
     */
429
    private function filterExcludedAttributes(string $attr): bool
430
    {
431
        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

431
        return !in_array($attr, /** @scrutinizer ignore-type */ $this->exclude);
Loading history...
432
    }
433
434
435
    /**
436
     * Assemble fticks log string
437
     *
438
     * @param array $fticks
439
     * @return string
440
     */
441
    private function assembleFticksLogString(array $fticks): string
442
    {
443
        $attributes = implode(
444
            '#',
445
            array_map(
446
                /**
447
                 * @param  string $k
448
                 * @param  string $v
449
                 * @return string
450
                 */
451
                function ($k, $v) {
452
                    return $k . '=' . $this->escapeFticks(strval($v));
453
                },
454
                array_keys($fticks),
455
                $fticks
456
            )
457
        );
458
459
        return sprintf('F-TICKS/%s/%s#%s#', $this->federation, self::$fticksVersion, $attributes);
460
    }
461
}
462