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

310
            $this->warnRiskyLogSettings($defaultFacility, /** @scrutinizer ignore-type */ $defaultProcessName);
Loading history...
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

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