Passed
Push — master ( 24a07f...2eea81 )
by Tim
02:46
created

Negotiate::createBinding()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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

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