Passed
Push — master ( 218f07...3b5891 )
by Tim
01:32
created

Negotiate::lookupUserData()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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