Passed
Branch feature/channel-binding (3900d2)
by Tim
02:37
created

Negotiate::authenticate()   F

Complexity

Conditions 23
Paths 474

Size

Total Lines 132
Code Lines 83

Duplication

Lines 0
Ratio 0 %

Importance

Changes 5
Bugs 0 Features 0
Metric Value
cc 23
eloc 83
c 5
b 0
f 0
nc 474
nop 1
dl 0
loc 132
rs 0.7305

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, 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 version_compare;
26
27
/**
28
 * The Negotiate module. Allows for password-less, secure login by Kerberos and Negotiate.
29
 *
30
 * @package simplesamlphp/simplesamlphp-module-negotiate
31
 */
32
class Negotiate extends Auth\Source
33
{
34
    // Constants used in the module
35
    public const STAGEID = '\SimpleSAML\Module\negotiate\Auth\Source\Negotiate.StageId';
36
37
    public const AUTHID = '\SimpleSAML\Module\negotiate\Auth\Source\Negotiate.AuthId';
38
39
    /** @var string|null */
40
    protected ?string $backend = null;
41
42
    /** @var string */
43
    protected string $fallback;
44
45
    /** @var string */
46
    protected string $keytab;
47
48
    /** @var string|integer|null */
49
    protected $spn = null;
50
51
    /** @var array|null */
52
    protected ?array $subnet = null;
53
54
    /** @var array */
55
    private array $realms;
56
57
    /** @var string[] */
58
    private array $allowedCertificateHashes;
59
60
61
    /**
62
     * Constructor for this authentication source.
63
     *
64
     * @param array $info Information about this authentication source.
65
     * @param array $config The configuration of the module
66
     *
67
     * @throws \Exception If the KRB5 extension is not installed or active.
68
     */
69
    public function __construct(array $info, array $config)
70
    {
71
        if (!extension_loaded('krb5')) {
72
            throw new Exception('KRB5 Extension not installed');
73
        }
74
75
        // call the parent constructor first, as required by the interface
76
        parent::__construct($info, $config);
77
78
        $cfg = Configuration::loadFromArray($config);
79
        $this->fallback = $cfg->getOptionalString('fallback', null);
80
        $this->spn = $cfg->getOptionalValue('spn', null);
81
        $configUtils = new Utils\Config();
82
        $this->keytab = $configUtils->getCertPath($cfg->getString('keytab'));
83
        $this->subnet = $cfg->getOptionalArray('subnet', null);
84
        $this->realms = $cfg->getArray('realms');
85
        $this->allowedCertificateHashes = $cfg->getOptionalArray('allowedCertificateHashes', null);
86
    }
87
88
89
    /**
90
     * The inner workings of the module.
91
     *
92
     * Checks to see if client is in the defined subnets (if defined in config). Sends the client a 401 Negotiate and
93
     * responds to the result. If the client fails to provide a proper Kerberos ticket, the login process is handed over
94
     * to the 'fallback' module defined in the config.
95
     *
96
     * LDAP is used as a user metadata source.
97
     *
98
     * @param array &$state Information about the current authentication.
99
     */
100
    public function authenticate(array &$state): void
101
    {
102
        // set the default backend to config
103
        $state['LogoutState'] = [
104
            'negotiate:backend' => $this->fallback,
105
        ];
106
        $state['negotiate:authId'] = $this->authId;
107
108
109
        // check for disabled SPs. The disable flag is stored in the SP metadata
110
        if (array_key_exists('SPMetadata', $state) && $this->spDisabledInMetadata($state['SPMetadata'])) {
111
            $this->fallBack($state);
112
        }
113
114
        /* Go straight to fallback if Negotiate is disabled or if you are sent back to the IdP directly from the SP
115
        after having logged out. */
116
        $session = Session::getSessionFromRequest();
117
        $disabled = $session->getData('negotiate:disable', 'session');
118
119
        if (
120
            $disabled ||
121
            (!empty($_COOKIE['NEGOTIATE_AUTOLOGIN_DISABLE_PERMANENT']) &&
122
            $_COOKIE['NEGOTIATE_AUTOLOGIN_DISABLE_PERMANENT'] === 'true')
123
        ) {
124
            Logger::debug('Negotiate - session disabled. falling back');
125
            $this->fallBack($state);
126
            return;
127
        }
128
129
        if (!$this->checkMask()) {
130
            Logger::debug('Negotiate - IP matches blacklisted subnets. falling back');
131
            $this->fallBack($state);
132
            return;
133
        }
134
135
        Logger::debug('Negotiate - authenticate(): looking for authentication header');
136
        if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
137
            Logger::debug('Negotiate - authenticate(): Authentication header found');
138
139
            Assert::true(is_string($this->spn) || (is_int($this->spn) && ($this->spn === 0)) || is_null($this->spn));
140
141
            // attempt Kerberos authentication
142
            try {
143
                $reply = null;
144
                if (empty($this->allowedCertificateHashes) || version_compare(phpversion('krb5'), '1.1.6', '<')) {
145
                    Logger::debug('Negotiate - authenticate(): Trying to authenticate without channel binding.');
146
                    $auth = new KRB5NegotiateAuth($this->keytab, $this->spn);
147
                    $reply = $auth->doAuthentication();
148
                } else {
149
                    Logger::debug('Negotiate - authenticate(): Trying to authenticate with channel binding.');
150
                    foreach ($this->allowedCertificateHashes as $hash) {
151
                        $binding = $this->createBinding($hash);
152
                        $auth = new KRB5NegotiateAuth($this->keytab, $this->spn, $binding);
153
                        try {
154
                            $reply = $auth->doAuthentication();
155
                            break;
156
                        } catch (Exception $e) {
157
                            continue;
158
                        }
159
                    }
160
161
                    if ($reply) {
162
                        Logger::debug(sprintf(
163
                            'Negotiate - authenticate(): Authentication with channel binding succeeded using hash; %s.',
164
                            $hash,
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $hash seems to be defined by a foreach iteration on line 150. Are you sure the iterator is never empty, otherwise this variable is not defined?
Loading history...
165
                        ));
166
                    } else {
167
                        throw $e;
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $e seems to be defined by a foreach iteration on line 150. Are you sure the iterator is never empty, otherwise this variable is not defined?
Loading history...
168
                    }
169
                }
170
            } catch (Exception $e) {
171
                list($mech,) = explode(' ', $_SERVER['HTTP_AUTHORIZATION'], 2);
172
                if (strtolower($mech) === 'basic') {
173
                    Logger::debug('Negotiate - authenticate(): Basic found. Skipping.');
174
                } elseif (strtolower($mech) !== 'negotiate') {
175
                    Logger::debug('Negotiate - authenticate(): No "Negotiate" found. Skipping.');
176
                }
177
                Logger::error('Negotiate - authenticate(): doAuthentication() exception: ' . $e->getMessage());
178
            }
179
180
            if ($reply) {
181
                // success! krb TGS received
182
                $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...
183
                Logger::info('Negotiate - authenticate(): ' . $userPrincipalName . ' authenticated.');
184
185
                // Search for the corresponding realm and set current variables
186
                @list($uid, $realmName) = preg_split('/@/', $userPrincipalName, 2);
187
                /** @psalm-var string $realmName */
188
                Assert::notNull($realmName);
189
190
                // Use the correct realm
191
                if (isset($this->realms[$realmName])) {
192
                    Logger::info(sprintf('Negotiate - setting realm parameters for "%s".', $realmName));
193
                    $this->backend = $this->realms[$realmName];
194
                } elseif (isset($this->realms['*'])) {
195
                    // Use default realm ("*"), if set
196
                    Logger::info('Negotiate - setting realm parameters with default realm.');
197
                    $this->backend = $this->realms['*'];
198
                } else {
199
                    // No corresponding realm found, cancel
200
                    $this->fallBack($state);
201
                    return;
202
                }
203
204
                if (($lookup = $this->lookupUserData($uid)) !== null) {
205
                    $state['Attributes'] = $lookup;
206
                    // Override the backend so logout will know what to look for
207
                    $state['LogoutState'] = [
208
                        'negotiate:backend' => null,
209
                    ];
210
                    Logger::info('Negotiate - authenticate(): ' . $userPrincipalName . ' authorized.');
211
                    Auth\Source::completeAuth($state);
212
                    return;
213
                }
214
            } else {
215
                // Some error in the received ticket. Expired?
216
                Logger::info('Negotiate - authenticate(): Kerberos authN failed. Skipping.');
217
            }
218
        } else {
219
            // Save the $state array, so that we can restore if after a redirect
220
            Logger::debug('Negotiate - fallback: ' . $state['LogoutState']['negotiate:backend']);
221
            $id = Auth\State::saveState($state, self::STAGEID);
222
            $params = ['AuthState' => $id];
223
224
            // No auth token. Send it.
225
            Logger::debug('Negotiate - authenticate(): Sending Negotiate.');
226
            $this->sendNegotiate($params); // never returns
227
        }
228
229
        Logger::info('Negotiate - authenticate(): Client failed Negotiate. Falling back');
230
        $this->fallBack($state);
231
        return;
232
    }
233
234
235
    /**
236
     * @param array $spMetadata
237
     * @return bool
238
     */
239
    public function spDisabledInMetadata(array $spMetadata): bool
240
    {
241
        if (array_key_exists('negotiate:disable', $spMetadata)) {
242
            if ($spMetadata['negotiate:disable'] == true) {
243
                Logger::debug('Negotiate - SP disabled. falling back');
244
                return true;
245
            } else {
246
                Logger::debug('Negotiate - SP disable flag found but set to FALSE');
247
            }
248
        } else {
249
            Logger::debug('Negotiate - SP disable flag not found');
250
        }
251
        return false;
252
    }
253
254
255
    /**
256
     * checkMask() looks up the subnet config option and verifies
257
     * that the client is within that range.
258
     *
259
     * Will return TRUE if no subnet option is configured.
260
     *
261
     * @return bool
262
     */
263
    public function checkMask(): bool
264
    {
265
        // No subnet means all clients are accepted.
266
        if ($this->subnet === null) {
267
            return true;
268
        }
269
270
        $ip = Request::createFromGlobals()->getClientIp();
271
        Assert::notNull($ip, "Unable to determine client IP.");
272
273
        if (IpUtils::checkIp($ip, $this->subnet)) {
0 ignored issues
show
Bug introduced by
It seems like $ip can also be of type null; however, parameter $requestIp of Symfony\Component\HttpFo...tion\IpUtils::checkIp() 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

273
        if (IpUtils::checkIp(/** @scrutinizer ignore-type */ $ip, $this->subnet)) {
Loading history...
274
            Logger::debug('Negotiate: Client "' . $ip . '" matched subnet.');
275
            return true;
276
        }
277
278
        Logger::debug('Negotiate: Client "' . $ip . '" did not match subnet.');
279
        return false;
280
    }
281
282
283
    /**
284
     * Send the actual headers and body of the 401. Embedded in the body is a post that is triggered by JS if the client
285
     * wants to show the 401 message.
286
     *
287
     * @param array $params additional parameters to the URL in the URL in the body.
288
     */
289
    protected function sendNegotiate(array $params): void
290
    {
291
        $config = Configuration::getInstance();
292
293
        $url = htmlspecialchars(Module::getModuleURL('negotiate/backend', $params));
294
295
        $t = new Template($config, 'negotiate:redirect.twig');
296
        $t->setStatusCode(401);
297
        $t->headers->set('WWW-Authenticate', 'Negotiate');
298
        $t->data['baseurlpath'] = Module::getModuleURL('negotiate');
299
        $t->data['url'] = $url;
300
        $t->send();
301
        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...
302
    }
303
304
305
    /**
306
     * Passes control of the login process to a different module.
307
     *
308
     * @param array $state Information about the current authentication.
309
     *
310
     * @throws \SimpleSAML\Error\Error If couldn't determine the auth source.
311
     * @throws \SimpleSAML\Error\Exception
312
     * @throws \Exception
313
     */
314
    public static function fallBack(array &$state): void // never
315
    {
316
        $authId = $state['LogoutState']['negotiate:backend'];
317
        if ($authId === null) {
318
            throw new Error\Error([500, "Unable to determine auth source."]);
319
        }
320
321
        /** @psalm-var \SimpleSAML\Auth\Source|null $source */
322
        $source = Auth\Source::getById($authId);
323
        if ($source === null) {
324
            throw new Exception('Could not find authentication source with id ' . $state[self::AUTHID]);
325
        }
326
327
        try {
328
            $source->authenticate($state);
329
        } catch (Error\Exception $e) {
330
            Auth\State::throwException($state, $e);
331
        } catch (Exception $e) {
332
            $e = new Error\UnserializableException($e);
333
            Auth\State::throwException($state, $e);
334
        }
335
336
        // fallBack never returns after loginCompleted()
337
        Logger::debug('Negotiate: backend returned');
338
        self::loginCompleted($state);
339
    }
340
341
342
    /**
343
     * Looks up what attributes to fetch from SP metadata and searches the directory.
344
     *
345
     * @param string $uid The user identifier.
346
     *
347
     * @return array|null The attributes for the user or NULL if not found.
348
     */
349
    protected function lookupUserData(string $uid): ?array
350
    {
351
        /**
352
         * @var \SimpleSAML\Module\ldap\Auth\Source\Ldap|null $source
353
         * @psalm-var string $this->backend - We only reach this method when $this->backend is set
354
         */
355
        $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

355
        $source = Auth\Source::getById(/** @scrutinizer ignore-type */ $this->backend);
Loading history...
356
        if ($source === null) {
357
            throw new Exception('Could not find authentication source with id ' . $this->backend);
358
        }
359
360
        try {
361
            return $source->getAttributes($uid);
362
        } catch (Error\Exception $e) {
363
            Logger::debug('Negotiate - ldap lookup failed: ' . $e);
364
            return null;
365
        }
366
    }
367
368
369
    /**
370
     * Log out from this authentication source.
371
     *
372
     * This method either logs the user out from Negotiate or passes the
373
     * logout call to the fallback module.
374
     *
375
     * @param array &$state Information about the current logout operation.
376
     */
377
    public function logout(array &$state): void
378
    {
379
        // get the source that was used to authenticate
380
        $authId = $state['LogoutState']['negotiate:backend'];
381
        Logger::debug('Negotiate - logout has the following authId: "' . $authId . '"');
382
383
        if ($authId === null) {
384
            $session = Session::getSessionFromRequest();
385
            $session->setData('negotiate:disable', 'session', true, 24 * 60 * 60);
386
            parent::logout($state);
387
        } else {
388
            /** @psalm-var \SimpleSAML\Module\negotiate\Auth\Source\Negotiate|null $source */
389
            $source = Auth\Source::getById($authId);
390
            if ($source === null) {
391
                throw new Exception('Could not find authentication source with id ' . $state[self::AUTHID]);
392
            }
393
            $source->logout($state);
394
        }
395
    }
396
397
398
    /**
399
     * @param string $hash
400
     * @return \GSSAPIChannelBinding
401
     */
402
    private function createBinding(string $hash): GSSAPIChannelBinding
403
    {
404
        $binding = new GSSAPIChannelBinding();
405
        $binding->setApplicationData(sprintf(
406
            '%s:%s',
407
            'tls-server-end-point',
408
            pack('H*', $hash),
409
        ));
410
411
        return $binding;
412
    }
413
}
414