Passed
Pull Request — master (#7)
by
unknown
16:06 queued 44s
created

Fticks::generatePNhash()   B

Complexity

Conditions 7
Paths 21

Size

Total Lines 34
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 20
c 0
b 0
f 0
dl 0
loc 34
rs 8.6666
cc 7
nc 21
nop 1
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_key_exists;
18
use function array_keys;
19
use function array_map;
20
use function boolval;
21
use function constant;
22
use function defined;
23
use function gethostbyname;
24
use function gethostname;
25
use function gmdate;
26
use function hash;
27
use function hash_algos;
28
use function implode;
29
use function in_array;
30
use function is_array;
31
use function is_string;
32
use function openlog;
33
use function posix_getpid;
34
use function preg_match;
35
use function preg_replace;
36
use function reset;
37
use function socket_create;
38
use function socket_sendto;
39
use function sprintf;
40
use function strlen;
41
use function syslog;
42
43
/**
44
 * Filter to log F-ticks stats data
45
 * See also:
46
 * - https://wiki.geant.org/display/gn42jra3/F-ticks+standard
47
 * - https://tools.ietf.org/html/draft-johansson-fticks-00
48
 *
49
 * @copyright Copyright (c) 2019, South African Identity Federation
50
 * @package   SimpleSAMLphp
51
 */
52
class Fticks extends Auth\ProcessingFilter
53
{
54
    /** @var string F-ticks version number */
55
    private static string $fticksVersion = '1.0';
56
57
    /** @var string F-ticks federation identifier */
58
    private string $federation;
59
60
    /** @var string A salt to apply when digesting usernames (defaults to config file salt) */
61
    private string $salt;
62
63
    /** @var string The logging backend */
64
    private string $logdest = 'simplesamlphp';
65
66
    /** @var array Backend specific logging config */
67
    private array $logconfig = [];
68
69
    /** @var string The username attribute to use */
70
    private string $identifyingAttribute = 'eduPersonPrincipalName';
71
72
    /** @var string|null The realm attribute to use */
73
    private ?string $realm = null;
74
75
    /** @var string The hashing algorithm to use */
76
    private string $algorithm = 'sha256';
77
78
    /** @var array F-ticks attributes to exclude */
79
    private array $exclude = [];
80
81
    /** @var bool Enable legacy handing of PN (for backwards compatibility) */
82
    private string $pnHashIsTargeted = 'none';
83
84
85
    /**
86
     * Log a message to the desired destination
87
     *
88
     * @param  string $msg message to log
89
     */
90
    private function log(string $msg): void
91
    {
92
        switch ($this->logdest) {
93
            /* local syslog call, avoiding SimpleSAMLphp's wrapping */
94
            case 'local':
95
            case 'syslog':
96
                Assert::keyExists($this->logconfig, 'processname');
97
                Assert::keyExists($this->logconfig, 'facility');
98
99
                openlog($this->logconfig['processname'], LOG_PID, $this->logconfig['facility']);
100
                syslog(array_key_exists('priority', $this->logconfig) ? $this->logconfig['priority'] : LOG_INFO, $msg);
101
                break;
102
103
            /* remote syslog call via UDP */
104
            case 'remote':
105
                Assert::keyExists($this->logconfig, 'processname');
106
                Assert::keyExists($this->logconfig, 'facility');
107
108
                /* assemble a syslog message per RFC 5424 */
109
                $rfc5424_message = sprintf(
110
                    '<%d>',
111
                    ((($this->logconfig['facility'] & 0x03f8) >> 3) * 8) +
112
                    (array_key_exists('priority', $this->logconfig) ? $this->logconfig['priority'] : LOG_INFO),
113
                ); // pri
114
                $rfc5424_message .= '1 '; // ver
115
                $rfc5424_message .= gmdate('Y-m-d\TH:i:s.v\Z '); // timestamp
116
                $rfc5424_message .= gethostname() . ' '; // hostname
117
                $rfc5424_message .= $this->logconfig['processname'] . ' '; // app-name
118
                $rfc5424_message .= posix_getpid() . ' '; // procid
119
                $rfc5424_message .= '- '; // msgid
120
                $rfc5424_message .= '- '; // structured-data
121
                $rfc5424_message .= $msg;
122
                /* send it to the remote host */
123
                $sock = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
124
                socket_sendto(
125
                    $sock,
126
                    $rfc5424_message,
127
                    strlen($rfc5424_message),
128
                    0,
129
                    gethostbyname(array_key_exists('host', $this->logconfig) ? $this->logconfig['host'] : 'localhost'),
130
                    array_key_exists('port', $this->logconfig) ? $this->logconfig['port'] : 514,
131
                );
132
                break;
133
134
            case 'errorlog':
135
                error_log($msg);
136
                break;
137
138
            /* mostly for unit testing */
139
            case 'stdout':
140
                echo $msg . "\n";
141
                break;
142
143
            /* SimpleSAMLphp's builtin logging */
144
            case 'simplesamlphp':
145
            default:
146
                Logger::stats($msg);
147
                break;
148
        }
149
    }
150
151
152
    /**
153
     * Generate a PN hash
154
     *
155
     * @param  array $state
156
     * @return string|null $hash
157
     */
158
    private function generatePNhash(array &$state): ?string
159
    {
160
        /* get a user id */
161
        Assert::keyExists($state, 'Attributes');
162
163
        $uid = null;
164
        if (array_key_exists($this->identifyingAttribute, $state['Attributes'])) {
165
            if (is_array($state['Attributes'][$this->identifyingAttribute])) {
166
                $uid = reset($state['Attributes'][$this->identifyingAttribute]);
167
            } else {
168
                $uid = $state['Attributes'][$this->identifyingAttribute];
169
            }
170
        }
171
172
        /* calculate a hash */
173
        if ($uid !== null) {
174
            $userdata = $this->federation;
175
            if (in_array($this->pnHashIsTargeted, ['source', 'both'])) {
176
                if (array_key_exists('saml:sp:IdP', $state)) {
177
                    $userdata .= strlen($state['saml:sp:IdP']) . ':' . $state['saml:sp:IdP'];
178
                } else {
179
                    $userdata .= strlen($state['Source']['entityid']) . ':' . $state['Source']['entityid'];
180
                }
181
            }
182
            if (in_array($this->pnHashIsTargeted, ['destination', 'both'])) {
183
                $userdata .= strlen($state['Destination']['entityid']) . ':' . $state['Destination']['entityid'];
184
            }
185
            $userdata .= strlen($uid) . ':' . $uid;
186
            $userdata .= $this->salt;
187
188
            return hash($this->algorithm, $userdata);
189
        }
190
191
        return null;
192
    }
193
194
195
    /**
196
     * Escape F-ticks values
197
     *
198
     * value = 1*( ALPHA / DIGIT / '_' / '-' / ':' / '.' / ',' / ';')
199
     * ... but add a / for entityIDs
200
     *
201
     * @param  string $value
202
     * @return string $value
203
     */
204
    private function escapeFticks(string $value): string
205
    {
206
        return preg_replace('/[^A-Za-z0-9_\-:.,;\/]+/', '', $value);
207
    }
208
209
210
    /**
211
     * Initialize this filter, parse configuration.
212
     *
213
     * @param  array $config Configuration information about this filter.
214
     * @param  mixed $reserved For future use.
215
     * @throws \SimpleSAML\Error\Exception
216
     */
217
    public function __construct(array $config, $reserved)
218
    {
219
        parent::__construct($config, $reserved);
220
221
        Assert::keyExists($config, 'federation', 'Federation identifier must be set', Error\Exception::class);
222
        Assert::string($config['federation'], 'Federation identifier must be a string', Error\Exception::class);
223
        $this->federation = $config['federation'];
224
225
        if (array_key_exists('salt', $config)) {
226
            Assert::string($config['salt'], 'Salt must be a string', Error\Exception::class);
227
            $this->salt = $config['salt'];
228
        } else {
229
            $configUtils = new Utils\Config();
230
            $this->salt = $configUtils->getSecretSalt();
231
        }
232
233
        if (array_key_exists('identifyingAttribute', $config)) {
234
            Assert::string(
235
                $config['identifyingAttribute'],
236
                'identifyingAttribute must be a string',
237
                Error\Exception::class,
238
            );
239
            $this->identifyingAttribute = $config['identifyingAttribute'];
240
        }
241
242
        if (array_key_exists('realm', $config)) {
243
            Assert::string($config['realm'], 'Realm must be a string', Error\Exception::class);
244
            $this->realm = $config['realm'];
245
        }
246
247
        if (array_key_exists('algorithm', $config)) {
248
            if (
249
                is_string($config['algorithm'])
250
                && in_array($config['algorithm'], hash_algos())
251
            ) {
252
                $this->algorithm = $config['algorithm'];
253
            } else {
254
                throw new Error\Exception('algorithm must be a hash algorithm listed in hash_algos()');
255
            }
256
        }
257
258
        if (array_key_exists('exclude', $config)) {
259
            if (is_array($config['exclude'])) {
260
                $this->exclude = $config['exclude'];
261
            } elseif (is_string($config['exclude'])) {
262
                $this->exclude = [$config['exclude']];
263
            } else {
264
                throw new Error\Exception('F-ticks exclude must be an array');
265
            }
266
        }
267
268
        if (array_key_exists('pnHashIsTargeted', $config)) {
269
            if (
270
                is_string($config['pnHashIsTargeted']) &&
271
                in_array($config['pnHashIsTargeted'], ['source', 'destination', 'both', 'none'])
272
            ) {
273
                $this->pnHashIsTargeted = $config['pnHashIsTargeted'];
274
            } else {
275
                throw new Error\Exception(
276
                    'F-ticks log pnHashIsTargeted must be one of [source, destnation, both, none]',
277
                );
278
            }
279
        }
280
281
        if (array_key_exists('logdest', $config)) {
282
            if (
283
                is_string($config['logdest']) &&
284
                in_array($config['logdest'], ['local', 'syslog', 'remote', 'stdout', 'errorlog', 'simplesamlphp'])
285
            ) {
286
                $this->logdest = $config['logdest'];
287
            } else {
288
                throw new Error\Exception(
289
                    'F-ticks log destination must be one of [local, remote, stdout, errorlog, simplesamlphp]',
290
                );
291
            }
292
        }
293
294
        /* match SSP config or we risk mucking up the openlog call */
295
        $globalConfig = Configuration::getInstance();
296
        $defaultFacility = $globalConfig->getOptionalInteger(
297
            'logging.facility',
298
            defined('LOG_LOCAL5') ? constant('LOG_LOCAL5') : LOG_USER,
299
        );
300
        $defaultProcessName = $globalConfig->getOptionalString('logging.processname', 'SimpleSAMLphp');
301
        if (array_key_exists('logconfig', $config)) {
302
            if (is_array($config['logconfig'])) {
303
                $this->logconfig = $config['logconfig'];
304
            } else {
305
                throw new Error\Exception('F-ticks logconfig must be an array');
306
            }
307
        }
308
        if (!array_key_exists('facility', $this->logconfig)) {
309
            $this->logconfig['facility'] = $defaultFacility;
310
        }
311
        if (!array_key_exists('processname', $this->logconfig)) {
312
            $this->logconfig['processname'] = $defaultProcessName;
313
        }
314
315
        /* warn if we risk mucking up the openlog call (doesn't matter for remote syslog) */
316
        if (in_array($this->logdest, ['local', 'syslog'])) {
317
            $this->warnRiskyLogSettings($defaultFacility, $defaultProcessName);
0 ignored issues
show
Bug introduced by
It seems like $defaultFacility can also be of type null; however, parameter $defaultFacility of SimpleSAML\Module\fticks...:warnRiskyLogSettings() does only seem to accept integer, 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

317
            $this->warnRiskyLogSettings(/** @scrutinizer ignore-type */ $defaultFacility, $defaultProcessName);
Loading history...
Bug introduced by
It seems like $defaultProcessName can also be of type null; however, parameter $defaultProcessName of SimpleSAML\Module\fticks...:warnRiskyLogSettings() does only seem to accept 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

317
            $this->warnRiskyLogSettings($defaultFacility, /** @scrutinizer ignore-type */ $defaultProcessName);
Loading history...
318
        }
319
    }
320
321
322
    /**
323
     * Warn about risky logger settings
324
     *
325
     * @param int $defaultFacility
326
     * @param string $defaultProcessName
327
     * @return void
328
     */
329
    private function warnRiskyLogSettings(int $defaultFacility, string $defaultProcessName): void
330
    {
331
        if (
332
            array_key_exists('facility', $this->logconfig)
333
            && ($this->logconfig['facility'] !== $defaultFacility)
334
        ) {
335
            Logger::warning(
336
                'F-ticks syslog facility differs from global config which may cause'
337
                . ' SimpleSAMLphp\'s logging to behave inconsistently',
338
            );
339
        }
340
        if (
341
            array_key_exists('processname', $this->logconfig)
342
            && ($this->logconfig['processname'] !== $defaultProcessName)
343
        ) {
344
            Logger::warning(
345
                'F-ticks syslog processname differs from global config which may cause'
346
                . ' SimpleSAMLphp\'s logging to behave inconsistently',
347
            );
348
        }
349
    }
350
351
352
    /**
353
     * Process this filter
354
     *
355
     * @param  mixed &$state
356
     */
357
    public function process(array &$state): void
358
    {
359
        Assert::keyExists($state, 'Destination');
360
        Assert::keyExists($state['Destination'], 'entityid');
361
        Assert::keyExists($state, 'Source');
362
        Assert::keyExists($state['Source'], 'entityid');
363
364
        $fticks = [];
365
366
        /* AFAIK the AuthProc will only execute if there is prior success */
367
        $fticks['RESULT'] = 'OK';
368
369
        /* SAML IdP entity Id */
370
        if (array_key_exists('saml:sp:IdP', $state)) {
371
            $fticks['AP'] = $state['saml:sp:IdP'];
372
        } else {
373
            $fticks['AP'] = $state['Source']['entityid'];
374
        }
375
376
        /* SAML SP entity Id */
377
        $fticks['RP'] = $state['Destination']['entityid'];
378
379
        /* SAML session id */
380
        $session = Session::getSessionFromRequest();
381
        $fticks['CSI'] = $session->getTrackID();
382
383
        /* Authentication method identifier */
384
        if (
385
            array_key_exists('saml:sp:State', $state)
386
            && array_key_exists('saml:sp:AuthnContext', $state['saml:sp:State'])
387
        ) {
388
            $fticks['AM'] = $state['saml:sp:State']['saml:sp:AuthnContext'];
389
        } elseif (
390
            array_key_exists('SimpleSAML_Auth_State.stage', $state)
391
            && preg_match('/UserPass/', $state['SimpleSAML_Auth_State.stage'])
392
        ) {
393
            /* hack to try identify LDAP et al as Password */
394
            $fticks['AM'] = Constants::AC_PASSWORD;
395
        }
396
397
        /* ePTID */
398
        $pn = $this->generatePNhash($state);
399
        if ($pn !== null) {
400
            $fticks['PN'] = $pn;
401
        }
402
403
        /* timestamp */
404
        if (
405
            array_key_exists('saml:sp:State', $state)
406
            && array_key_exists('saml:AuthnInstant', $state['saml:sp:State'])
407
        ) {
408
            $fticks['TS'] = $state['saml:sp:State']['saml:AuthnInstant'];
409
        } else {
410
            $fticks['TS'] = time();
411
        }
412
413
        /* realm */
414
        if ($this->realm !== null) {
415
            Assert::keyExists($state, 'Attributes');
416
            if (array_key_exists($this->realm, $state['Attributes'])) {
417
                if (is_array($state['Attributes'][$this->realm])) {
418
                    $fticks['REALM'] = $state['Attributes'][$this->realm][0];
419
                } else {
420
                    $fticks['REALM'] = $state['Attributes'][$this->realm];
421
                }
422
            }
423
        }
424
425
        /* allow some attributes to be excluded */
426
        if (!empty($this->exclude)) {
427
            $fticks = array_filter($fticks, [$this, 'filterExcludedAttributes'], ARRAY_FILTER_USE_KEY);
428
        }
429
430
        /* assemble an F-ticks log string */
431
        $this->log($this->assembleFticksLogString($fticks));
432
    }
433
434
435
    /**
436
     * Callback method to filter excluded attributes
437
     *
438
     * @param string $attr
439
     * @return bool
440
     */
441
    private function filterExcludedAttributes(string $attr): bool
442
    {
443
        return !in_array($attr, $this->exclude);
444
    }
445
446
447
    /**
448
     * Assemble fticks log string
449
     *
450
     * @param array $fticks
451
     * @return string
452
     */
453
    private function assembleFticksLogString(array $fticks): string
454
    {
455
        $attributes = implode(
456
            '#',
457
            array_map(
458
                /**
459
                 * @param  string $k
460
                 * @param  string $v
461
                 * @return string
462
                 */
463
                function ($k, $v) {
464
                    return $k . '=' . $this->escapeFticks(strval($v));
465
                },
466
                array_keys($fticks),
467
                $fticks,
468
            ),
469
        );
470
471
        return sprintf('F-TICKS/%s/%s#%s#', $this->federation, self::$fticksVersion, $attributes);
472
    }
473
}
474