Passed
Push — master ( 5dd66e...50fceb )
by Tim
02:36
created

Negotiate::authenticate()   F

Complexity

Conditions 27
Paths 250

Size

Total Lines 140
Code Lines 88

Duplication

Lines 0
Ratio 0 %

Importance

Changes 11
Bugs 0 Features 0
Metric Value
cc 27
eloc 88
c 11
b 0
f 0
nc 250
nop 1
dl 0
loc 140
rs 2.7083

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\negotiate\Auth\Source;
6
7
use Exception;
8
use GSSAPIChannelBinding;
9
use KRB5NegotiateAuth;
10
use SimpleSAML\Assert\Assert;
11
use SimpleSAML\Auth;
12
use SimpleSAML\Configuration;
13
use SimpleSAML\Error;
14
use SimpleSAML\Logger;
15
use SimpleSAML\Module;
16
use SimpleSAML\Session;
17
use SimpleSAML\Utils;
18
use SimpleSAML\XHTML\Template;
19
use Symfony\Component\HttpFoundation\IpUtils;
20
use Symfony\Component\HttpFoundation\Request;
21
22
use function array_key_exists;
23
use function extension_loaded;
24
use function htmlspecialchars;
25
use function is_int;
26
use function is_null;
27
use function is_string;
28
use function pack;
29
use function phpversion;
30
use function preg_split;
31
use function sprintf;
32
use function str_replace;
33
use function strval;
34
use function version_compare;
35
36
/**
37
 * The Negotiate module. Allows for password-less, secure login by Kerberos and Negotiate.
38
 *
39
 * @package simplesamlphp/simplesamlphp-module-negotiate
40
 */
41
class Negotiate extends Auth\Source
42
{
43
    // Constants used in the module
44
    public const STAGEID = '\SimpleSAML\Module\negotiate\Auth\Source\Negotiate.StageId';
45
46
    public const AUTHID = '\SimpleSAML\Module\negotiate\Auth\Source\Negotiate.AuthId';
47
48
49
    /** @var string|null */
50
    protected ?string $backend = null;
51
52
    /** @var string|null */
53
    protected ?string $fallback;
54
55
    /** @var string */
56
    protected string $keytab;
57
58
    /** @var string|integer|null */
59
    protected $spn = null;
60
61
    /** @var string[]|null */
62
    protected ?array $subnet = null;
63
64
    /** @var string[] */
65
    private array $realms;
66
67
    /** @var string[] */
68
    private array $allowedCertificateHashes;
69
70
    /** @var bool */
71
    private bool $enforceChannelBinding = false;
72
73
74
    /**
75
     * Constructor for this authentication source.
76
     *
77
     * @param array<mixed> $info Information about this authentication source.
78
     * @param array<mixed> $config The configuration of the module
79
     *
80
     * @throws \Exception If the KRB5 extension is not installed or active.
81
     */
82
    public function __construct(array $info, array $config)
83
    {
84
        if (!extension_loaded('krb5')) {
85
            throw new Exception('KRB5 Extension not installed');
86
        }
87
88
        // call the parent constructor first, as required by the interface
89
        parent::__construct($info, $config);
90
91
        $cfg = Configuration::loadFromArray($config);
92
        $this->fallback = $cfg->getOptionalString('fallback', null);
93
        $this->spn = $cfg->getOptionalValue('spn', null);
94
        $configUtils = new Utils\Config();
95
        $this->keytab = $configUtils->getCertPath($cfg->getString('keytab'));
96
        $this->subnet = $cfg->getOptionalArray('subnet', null);
97
        $this->realms = $cfg->getArray('realms');
98
        $this->allowedCertificateHashes = $cfg->getOptionalArray('allowedCertificateHashes', []);
99
        $this->enforceChannelBinding = $cfg->getOptionalBoolean('enforceChannelBinding', false);
100
    }
101
102
103
    /**
104
     * The inner workings of the module.
105
     *
106
     * Checks to see if client is in the defined subnets (if defined in config). Sends the client a 401 Negotiate and
107
     * responds to the result. If the client fails to provide a proper Kerberos ticket, the login process is handed over
108
     * to the 'fallback' module defined in the config.
109
     *
110
     * LDAP is used as a user metadata source.
111
     *
112
     * @param array<mixed> &$state Information about the current authentication.
113
     */
114
    public function authenticate(array &$state): void
115
    {
116
        // set the default backend to config
117
        $state['LogoutState'] = [
118
            'negotiate:backend' => $this->fallback,
119
        ];
120
        $state['negotiate:authId'] = $this->authId;
121
122
123
        // check for disabled SPs. The disable flag is stored in the SP metadata
124
        if (array_key_exists('SPMetadata', $state) && $this->spDisabledInMetadata($state['SPMetadata'])) {
125
            $this->fallBack($state);
126
        }
127
128
        /* Go straight to fallback if Negotiate is disabled or if you are sent back to the IdP directly from the SP
129
        after having logged out. */
130
        $session = Session::getSessionFromRequest();
131
        $disabled = $session->getData('negotiate:disable', 'session');
132
133
        if (
134
            $disabled ||
135
            (array_key_exists('NEGOTIATE_AUTOLOGIN_DISABLE_PERMANENT', $_COOKIE) &&
136
            $_COOKIE['NEGOTIATE_AUTOLOGIN_DISABLE_PERMANENT'] === 'true')
137
        ) {
138
            Logger::debug('Negotiate - session disabled. falling back');
139
            $this->fallBack($state);
140
            return;
141
        }
142
143
        if (!$this->checkMask()) {
144
            Logger::debug('Negotiate - IP matches blacklisted subnets. falling back');
145
            $this->fallBack($state);
146
            return;
147
        }
148
149
        Logger::debug('Negotiate - authenticate(): looking for authentication header');
150
        if (array_key_exists('HTTP_AUTHORIZATION', $_SERVER) && !empty($_SERVER['HTTP_AUTHORIZATION'])) {
151
            Logger::debug('Negotiate - authenticate(): Authentication header found');
152
153
            Assert::true(is_string($this->spn) || (is_int($this->spn) && ($this->spn === 0)) || is_null($this->spn));
154
155
            list($mech,) = explode(' ', $_SERVER['HTTP_AUTHORIZATION'], 2);
156
            if (strtolower($mech) === 'basic') {
157
                Logger::debug('Negotiate - authenticate(): Basic found. Skipping.');
158
            } elseif (strtolower($mech) !== 'negotiate') {
159
                Logger::debug('Negotiate - authenticate(): No "Negotiate" found. Skipping.');
160
            } else {
161
                // attempt Kerberos authentication
162
                $reply = $auth = null;
163
164
                try {
165
                    if (version_compare(phpversion('krb5'), '1.1.6', '<')) {
166
                        Logger::debug(
167
                            'Negotiate - authenticate(): Trying to authenticate (channel binding not available).',
168
                        );
169
                        $auth = new KRB5NegotiateAuth($this->keytab, $this->spn);
170
                        $reply = $this->doAuthentication($auth);
171
                    } elseif (empty($this->allowedCertificateHashes) && $this->enforceChannelBinding === false) {
172
                        Logger::debug('Negotiate - authenticate(): Trying to authenticate without channel binding.');
173
                        $auth = new KRB5NegotiateAuth($this->keytab, $this->spn);
174
                        $reply = $this->doAuthentication($auth);
175
                    } else {
176
                        Logger::debug('Negotiate - authenticate(): Trying to authenticate with channel binding.');
177
178
                        $hashes = str_replace(':', '', $this->allowedCertificateHashes);
179
                        foreach ($hashes as $hash) {
180
                            $binding = $this->createBinding($hash);
181
                            $auth = new KRB5NegotiateAuth($this->keytab, $this->spn, $binding);
182
183
                            try {
184
                                $reply = $this->doAuthentication($auth, $hash);
185
                                break;
186
                            } catch (Exception $e) {
187
                                continue;
188
                            }
189
                        }
190
191
                        if ($auth === null || !$auth->isChannelBound()) {
192
                            throw new Error\Exception(
193
                                'Negotiate - authenticate(): Failed to perform channel binding using '
194
                                . 'any of the configured certificate hashes.',
195
                            );
196
                        }
197
                    }
198
                } catch (Exception $e) {
199
                    Logger::error('Negotiate - authenticate(): doAuthentication() exception: ' . $e->getMessage());
200
                }
201
202
                if ($reply && $auth !== null) {
203
                    // success! krb TGS received
204
                    $userPrincipalName = $auth->getAuthenticatedUser();
205
                    Logger::info('Negotiate - authenticate(): ' . $userPrincipalName . ' authenticated.');
206
207
                    // Search for the corresponding realm and set current variables
208
                    @list($uid, $realmName) = preg_split('/@/', $userPrincipalName, 2);
209
                    Assert::notNull($realmName);
210
211
                    // Use the correct realm
212
                    if (isset($this->realms[$realmName])) {
213
                        Logger::info(sprintf('Negotiate - setting realm parameters for "%s".', $realmName));
214
                        $this->backend = $this->realms[$realmName];
215
                    } elseif (isset($this->realms['*'])) {
216
                        // Use default realm ("*"), if set
217
                        Logger::info('Negotiate - setting realm parameters with default realm.');
218
                        $this->backend = $this->realms['*'];
219
                    } else {
220
                        // No corresponding realm found, cancel
221
                        $this->fallBack($state);
222
                        return;
223
                    }
224
225
                    if (($lookup = $this->lookupUserData($uid)) !== null) {
226
                        $state['Attributes'] = $lookup;
227
                        // Override the backend so logout will know what to look for
228
                        $state['LogoutState'] = [
229
                            'negotiate:backend' => $this->backend,
230
                        ];
231
                        Logger::info('Negotiate - authenticate(): ' . $userPrincipalName . ' authorized.');
232
                        Auth\Source::completeAuth($state);
233
                        return;
234
                    }
235
                } else {
236
                    // Some error in the received ticket. Expired?
237
                    Logger::info('Negotiate - authenticate(): Kerberos authN failed. Skipping.');
238
                }
239
            }
240
        } else {
241
            // Save the $state array, so that we can restore if after a redirect
242
            Logger::debug('Negotiate - fallback: ' . $state['LogoutState']['negotiate:backend']);
243
            $id = Auth\State::saveState($state, self::STAGEID);
244
            $params = ['AuthState' => $id];
245
246
            // No auth token. Send it.
247
            Logger::debug('Negotiate - authenticate(): Sending Negotiate.');
248
            $this->sendNegotiate($params); // never returns
249
        }
250
251
        Logger::info('Negotiate - authenticate(): Client failed Negotiate. Falling back');
252
        $this->fallBack($state);
253
        return;
254
    }
255
256
257
    private function doAuthentication(KRB5NegotiateAuth $auth, ?string $hash = null): bool
258
    {
259
        if ($this->enforceChannelBinding === true && ($hash === null)) {
260
            throw new Error\Exception(
261
                'Negotiate - doAuthenticate(): Channel binding is required, but the client '
262
                . 'did not provide binding info.',
263
            );
264
        }
265
266
        try {
267
            $reply = $auth->doAuthentication();
268
            if ($hash !== null) {
269
                Logger::debug(sprintf(
270
                    'Negotiate - doAuthenticate(): Authentication with channel binding succeeded using hash; %s.',
271
                    $hash,
272
                ));
273
            } else {
274
                Logger::debug(
275
                    'Negotiate - doAuthenticate(): Authentication without channel binding succeeded.',
276
                );
277
            }
278
            return $reply;
279
        } catch (Exception $e) {
280
            Logger::debug(sprintf(
281
                'Negotiate - doAuthenticate(): Authentication with channel binding failed using hash; %s.',
282
                strval($hash),
283
            ));
284
            throw $e;
285
        }
286
    }
287
288
289
    /**
290
     * @param array<mixed> $spMetadata
291
     * @return bool
292
     */
293
    public function spDisabledInMetadata(array $spMetadata): bool
294
    {
295
        if (array_key_exists('negotiate:disable', $spMetadata)) {
296
            if ($spMetadata['negotiate:disable'] == true) {
297
                Logger::debug('Negotiate - SP disabled. falling back');
298
                return true;
299
            } else {
300
                Logger::debug('Negotiate - SP disable flag found but set to FALSE');
301
            }
302
        } else {
303
            Logger::debug('Negotiate - SP disable flag not found');
304
        }
305
        return false;
306
    }
307
308
309
    /**
310
     * checkMask() looks up the subnet config option and verifies
311
     * that the client is within that range.
312
     *
313
     * Will return TRUE if no subnet option is configured.
314
     *
315
     * @return bool
316
     */
317
    public function checkMask(): bool
318
    {
319
        // No subnet means all clients are accepted.
320
        if ($this->subnet === null) {
321
            return true;
322
        }
323
324
        $ip = Request::createFromGlobals()->getClientIp() ?? '127.0.0.1';
325
        Assert::notNull($ip, "Unable to determine client IP.");
326
327
        if (IpUtils::checkIp($ip, $this->subnet)) {
328
            Logger::debug('Negotiate: Client "' . $ip . '" matched subnet.');
329
            return true;
330
        }
331
332
        Logger::debug('Negotiate: Client "' . $ip . '" did not match subnet.');
333
        return false;
334
    }
335
336
337
    /**
338
     * Send the actual headers and body of the 401. Embedded in the body is a post that is triggered by JS if the client
339
     * wants to show the 401 message.
340
     *
341
     * @param array<mixed> $params additional parameters to the URL in the URL in the body.
342
     */
343
    protected function sendNegotiate(array $params): void
344
    {
345
        $config = Configuration::getInstance();
346
347
        $url = htmlspecialchars(Module::getModuleURL('negotiate/backend', $params));
348
349
        $t = new Template($config, 'negotiate:redirect.twig');
350
        $t->setStatusCode(401);
351
        $t->headers->set('WWW-Authenticate', 'Negotiate');
352
        $t->data['baseurlpath'] = Module::getModuleURL('negotiate');
353
        $t->data['url'] = $url;
354
        $t->send();
355
        exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
356
    }
357
358
359
    /**
360
     * Passes control of the login process to a different module.
361
     *
362
     * @param array<mixed> $state Information about the current authentication.
363
     *
364
     * @throws \SimpleSAML\Error\Error If couldn't determine the auth source.
365
     * @throws \SimpleSAML\Error\Exception
366
     * @throws \Exception
367
     */
368
    public static function fallBack(array &$state): void // never
369
    {
370
        $authId = $state['LogoutState']['negotiate:backend'];
371
        if ($authId === null) {
372
            throw new Error\Error([500, "Unable to determine auth source."]);
373
        }
374
375
        $source = Auth\Source::getById($authId);
376
        if ($source === null) {
377
            throw new Exception('Could not find authentication source with id ' . $state[self::AUTHID]);
378
        }
379
380
        try {
381
            $source->authenticate($state);
382
        } catch (Error\Exception $e) {
383
            Auth\State::throwException($state, $e);
384
        } catch (Exception $e) {
385
            $e = new Error\UnserializableException($e);
386
            Auth\State::throwException($state, $e);
387
        }
388
389
        // fallBack never returns after loginCompleted()
390
        Logger::debug('Negotiate: backend returned');
391
        self::loginCompleted($state);
392
    }
393
394
395
    /**
396
     * Looks up what attributes to fetch from SP metadata and searches the directory.
397
     *
398
     * @param string $uid The user identifier.
399
     *
400
     * @return array<mixed>|null The attributes for the user or NULL if not found.
401
     */
402
    protected function lookupUserData(string $uid): ?array
403
    {
404
        /**
405
         * @var \SimpleSAML\Module\ldap\Auth\Source\Ldap|null $source
406
         */
407
        $source = Auth\Source::getById($this->backend);
0 ignored issues
show
Bug introduced by
It seems like $this->backend can also be of type null; however, parameter $authId of SimpleSAML\Auth\Source::getById() 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

407
        $source = Auth\Source::getById(/** @scrutinizer ignore-type */ $this->backend);
Loading history...
408
        if ($source === null) {
409
            throw new Exception('Could not find authentication source with id ' . $this->backend);
410
        }
411
412
        try {
413
            return $source->getAttributes($uid);
414
        } catch (Error\Exception $e) {
415
            Logger::debug('Negotiate - ldap lookup failed: ' . $e);
416
            return null;
417
        }
418
    }
419
420
421
    /**
422
     * Log out from this authentication source.
423
     *
424
     * This method either logs the user out from Negotiate or passes the
425
     * logout call to the fallback module.
426
     *
427
     * @param array<mixed> &$state Information about the current logout operation.
428
     */
429
    public function logout(array &$state): void
430
    {
431
        // get the source that was used to authenticate
432
        $authId = $state['negotiate:backend'];
433
        Logger::debug('Negotiate - logout has the following authId: "' . $authId . '"');
434
435
        if ($authId === null) {
436
            $session = Session::getSessionFromRequest();
437
            $session->setData('negotiate:disable', 'session', true, 24 * 60 * 60);
438
            parent::logout($state);
439
        } else {
440
            $source = Auth\Source::getById($authId);
441
            if ($source === null) {
442
                throw new Exception('Could not find authentication source with id ' . $state[self::AUTHID]);
443
            }
444
            $source->logout($state);
445
        }
446
    }
447
448
449
    /**
450
     * @param string $hash
451
     * @return \GSSAPIChannelBinding
452
     */
453
    private function createBinding(string $hash): GSSAPIChannelBinding
454
    {
455
        $binding = new GSSAPIChannelBinding();
456
        $binding->setApplicationData(sprintf(
457
            '%s:%s',
458
            'tls-server-end-point',
459
            pack('H*', $hash),
460
        ));
461
462
        return $binding;
463
    }
464
}
465