Passed
Push — master ( 630dfb...bd747e )
by Tim
03:47
created

Negotiate::authenticate()   F

Complexity

Conditions 19
Paths 440

Size

Total Lines 115
Code Lines 69

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 19
eloc 69
c 3
b 0
f 0
nc 440
nop 1
dl 0
loc 115
rs 1.1277

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 SimpleSAML\Logger;
8
use Webmozart\Assert\Assert;
9
10
/**
11
 * The Negotiate module. Allows for password-less, secure login by Kerberos and Negotiate.
12
 *
13
 * @author Mathias Meisfjordskar, University of Oslo <[email protected]>
14
 * @package SimpleSAMLphp
15
 */
16
class Negotiate extends \SimpleSAML\Auth\Source
17
{
18
    // Constants used in the module
19
    public const STAGEID = '\SimpleSAML\Module\negotiate\Auth\Source\Negotiate.StageId';
20
21
    public const AUTHID = '\SimpleSAML\Module\negotiate\Auth\Source\Negotiate.AuthId';
22
23
    /** @var \SimpleSAML\Module\ldap\Auth\Ldap */
24
    protected $ldap;
25
26
    /** @var string */
27
    protected $backend = '';
28
29
    /** @var string*/
30
    protected $hostname = '';
31
32
    /** @var int */
33
    protected $port = 389;
34
35
    /** @var bool */
36
    protected $referrals = true;
37
38
    /** @var bool */
39
    protected $enableTLS = false;
40
41
    /** @var bool */
42
    protected $debugLDAP = false;
43
44
    /** @var int */
45
    protected $timeout = 30;
46
47
    /** @var string */
48
    protected $keytab = '';
49
50
    /** @var string|integer|null */
51
    protected $spn = null;
52
53
    /** @var array */
54
    protected $base = [];
55
56
    /** @var array */
57
    protected $attr = ['uid'];
58
59
    /** @var array|null */
60
    protected $subnet = null;
61
62
    /** @var string|null */
63
    protected $admin_user = null;
64
65
    /** @var string|null */
66
    protected $admin_pw = null;
67
68
    /** @var array|null */
69
    protected $attributes = null;
70
71
72
    /**
73
     * Constructor for this authentication source.
74
     *
75
     * @param array $info Information about this authentication source.
76
     * @param array $config The configuration of the module
77
     *
78
     * @throws \Exception If the KRB5 extension is not installed or active.
79
     */
80
    public function __construct(array $info, array $config)
81
    {
82
        if (!extension_loaded('krb5')) {
83
            throw new \Exception('KRB5 Extension not installed');
84
        }
85
        // call the parent constructor first, as required by the interface
86
        parent::__construct($info, $config);
87
88
        $cfg = \SimpleSAML\Configuration::loadFromArray($config);
89
90
        $this->backend = $cfg->getString('fallback');
91
        $this->hostname = $cfg->getString('hostname');
92
        $this->port = $cfg->getInteger('port', 389);
93
        $this->referrals = $cfg->getBoolean('referrals', true);
94
        $this->enableTLS = $cfg->getBoolean('enable_tls', false);
95
        $this->debugLDAP = $cfg->getBoolean('debugLDAP', false);
96
        $this->timeout = $cfg->getInteger('timeout', 30);
97
        $this->keytab = \SimpleSAML\Utils\Config::getCertPath($cfg->getString('keytab'));
98
        $this->base = $cfg->getArrayizeString('base');
0 ignored issues
show
Documentation Bug introduced by
It seems like $cfg->getArrayizeString('base') can also be of type string. However, the property $base is declared as type array. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
99
        $this->attr = $cfg->getArrayizeString('attr', 'uid');
0 ignored issues
show
Documentation Bug introduced by
It seems like $cfg->getArrayizeString('attr', 'uid') can also be of type string. However, the property $attr is declared as type array. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
100
        $this->subnet = $cfg->getArray('subnet', null);
101
        $this->admin_user = $cfg->getString('adminUser', null);
102
        $this->admin_pw = $cfg->getString('adminPassword', null);
103
        $this->attributes = $cfg->getArray('attributes', null);
104
        $this->spn = $cfg->getValue('spn', null);
105
    }
106
107
108
    /**
109
     * The inner workings of the module.
110
     *
111
     * Checks to see if client is in the defined subnets (if defined in config). Sends the client a 401 Negotiate and
112
     * responds to the result. If the client fails to provide a proper Kerberos ticket, the login process is handed over
113
     * to the 'fallback' module defined in the config.
114
     *
115
     * LDAP is used as a user metadata source.
116
     *
117
     * @param array &$state Information about the current authentication.
118
     * @return void
119
     */
120
    public function authenticate(array &$state): void
121
    {
122
        // set the default backend to config
123
        $state['LogoutState'] = [
124
            'negotiate:backend' => $this->backend,
125
        ];
126
        $state['negotiate:authId'] = $this->authId;
127
128
129
        // check for disabled SPs. The disable flag is stored in the SP metadata
130
        if (array_key_exists('SPMetadata', $state) && $this->spDisabledInMetadata($state['SPMetadata'])) {
131
            $this->fallBack($state);
132
        }
133
        /* Go straight to fallback if Negotiate is disabled or if you are sent back to the IdP directly from the SP
134
        after having logged out. */
135
        $session = \SimpleSAML\Session::getSessionFromRequest();
136
        $disabled = $session->getData('negotiate:disable', 'session');
137
138
        if (
139
            $disabled
140
            || (!empty($_COOKIE['NEGOTIATE_AUTOLOGIN_DISABLE_PERMANENT'])
141
                && $_COOKIE['NEGOTIATE_AUTOLOGIN_DISABLE_PERMANENT'] === 'true')
142
        ) {
143
            Logger::debug('Negotiate - session disabled. falling back');
144
            $this->fallBack($state);
145
            // never executed
146
            assert(false);
147
        }
148
        $mask = $this->checkMask();
149
        if (!$mask) {
150
            $this->fallBack($state);
151
            // never executed
152
            assert(false);
153
        }
154
155
        Logger::debug('Negotiate - authenticate(): looking for Negotiate');
156
        if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
157
            Logger::debug('Negotiate - authenticate(): Negotiate found');
158
            $this->ldap = new \SimpleSAML\Module\ldap\Auth\Ldap(
159
                $this->hostname,
160
                $this->enableTLS,
161
                $this->debugLDAP,
162
                $this->timeout,
163
                $this->port,
164
                $this->referrals
165
            );
166
167
            list($mech,) = explode(' ', $_SERVER['HTTP_AUTHORIZATION'], 2);
168
            if (strtolower($mech) == 'basic') {
169
                Logger::debug('Negotiate - authenticate(): Basic found. Skipping.');
170
            } else {
171
                if (strtolower($mech) != 'negotiate') {
172
                    Logger::debug('Negotiate - authenticate(): No "Negotiate" found. Skipping.');
173
                }
174
            }
175
176
            Assert::true(is_string($this->spn) || (is_int($this->spn) && ($this->spn === 0)) || is_null($this->spn));
177
178
            if (version_compare(phpversion('krb5'), '1.1.3', '<')) {
179
                $auth = new \KRB5NegotiateAuth($this->keytab);
180
            } elseif (version_compare(phpversion('krb5'), '1.1.3', 'eq') && is_null($this->spn)) {
181
                /**
182
                 * This is a workaround for a bug in krb5 v1.1.3 that has been fixed in SVN, just not yet released.
183
                 * Once v1.1.4 is released, get rid of the elseif-clause and then make sure to mark the 
184
                 * v.1.1.3 version of the extension as a conflict in the composer.json file.
185
                 */
186
                $auth = new \KRB5NegotiateAuth($this->keytab);
187
            } else {
188
                $auth = new \KRB5NegotiateAuth($this->keytab, $this->spn);
189
            }
190
191
            // attempt Kerberos authentication
192
            try {
193
                $reply = $auth->doAuthentication();
194
            } catch (\Exception $e) {
195
                Logger::error('Negotiate - authenticate(): doAuthentication() exception: ' . $e->getMessage());
196
                $reply = null;
197
            }
198
199
            if ($reply) {
200
                // success! krb TGS received
201
                $user = $auth->getAuthenticatedUser();
202
                Logger::info('Negotiate - authenticate(): ' . $user . ' authenticated.');
203
                $lookup = $this->lookupUserData($user);
204
                if ($lookup !== 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(): ' . $user . ' authorized.');
211
                    \SimpleSAML\Auth\Source::completeAuth($state);
212
                    // Never reached.
213
                    assert(false);
214
                }
215
            } else {
216
                // Some error in the received ticket. Expired?
217
                Logger::info('Negotiate - authenticate(): Kerberos authN failed. Skipping.');
218
            }
219
        } else {
220
            // No auth token. Send it.
221
            Logger::debug('Negotiate - authenticate(): Sending Negotiate.');
222
            // Save the $state array, so that we can restore if after a redirect
223
            Logger::debug('Negotiate - fallback: ' . $state['LogoutState']['negotiate:backend']);
224
            $id = \SimpleSAML\Auth\State::saveState($state, self::STAGEID);
225
            $params = ['AuthState' => $id];
226
227
            $this->sendNegotiate($params);
228
            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...
229
        }
230
231
        Logger::info('Negotiate - authenticate(): Client failed Negotiate. Falling back');
232
        $this->fallBack($state);
233
        // The previous function never returns, so this code is never executed
234
        assert(false);
235
    }
236
237
238
    /**
239
     * @param array $spMetadata
240
     * @return bool
241
     */
242
    public function spDisabledInMetadata(array $spMetadata): bool
243
    {
244
        if (array_key_exists('negotiate:disable', $spMetadata)) {
245
            if ($spMetadata['negotiate:disable'] == true) {
246
                Logger::debug('Negotiate - SP disabled. falling back');
247
                return true;
248
            } else {
249
                Logger::debug('Negotiate - SP disable flag found but set to FALSE');
250
            }
251
        } else {
252
            Logger::debug('Negotiate - SP disable flag not found');
253
        }
254
        return false;
255
    }
256
257
258
    /**
259
     * checkMask() looks up the subnet config option and verifies
260
     * that the client is within that range.
261
     *
262
     * Will return TRUE if no subnet option is configured.
263
     *
264
     * @return bool
265
     */
266
    public function checkMask(): bool
267
    {
268
        // No subnet means all clients are accepted.
269
        if ($this->subnet === null) {
270
            return true;
271
        }
272
        $ip = $_SERVER['REMOTE_ADDR'];
273
        foreach ($this->subnet as $cidr) {
274
            $ret = \SimpleSAML\Utils\Net::ipCIDRcheck($cidr);
275
            if ($ret) {
276
                Logger::debug('Negotiate: Client "' . $ip . '" matched subnet.');
277
                return true;
278
            }
279
        }
280
        Logger::debug('Negotiate: Client "' . $ip . '" did not match subnet.');
281
        return false;
282
    }
283
284
285
    /**
286
     * Send the actual headers and body of the 401. Embedded in the body is a post that is triggered by JS if the client
287
     * wants to show the 401 message.
288
     *
289
     * @param array $params additional parameters to the URL in the URL in the body.
290
     * @return void
291
     */
292
    protected function sendNegotiate(array $params): void
293
    {
294
        $config = \SimpleSAML\Configuration::getInstance();
295
296
        $url = htmlspecialchars(\SimpleSAML\Module::getModuleURL('negotiate/backend', $params));
297
298
        $t = new \SimpleSAML\XHTML\Template($config, 'negotiate:redirect.twig');
299
        $t->setStatusCode(401);
300
        $t->headers->set('WWW-Authenticate', 'Negotiate');
301
        $t->data['baseurlpath'] = \SimpleSAML\Module::getModuleURL('negotiate');
302
        $t->data['url'] = $url;
303
        $t->send();
304
    }
305
306
307
    /**
308
     * Passes control of the login process to a different module.
309
     *
310
     * @param array $state Information about the current authentication.
311
     * @return void
312
     *
313
     * @throws \SimpleSAML\Error\Error If couldn't determine the auth source.
314
     * @throws \SimpleSAML\Error\Exception
315
     * @throws \Exception
316
     */
317
    public static function fallBack(array &$state): void
318
    {
319
        $authId = $state['LogoutState']['negotiate:backend'];
320
321
        if ($authId === null) {
322
            throw new \SimpleSAML\Error\Error([500, "Unable to determine auth source."]);
323
        }
324
325
        /** @psalm-var \SimpleSAML\Module\negotiate\Auth\Source\Negotiate|null $source */
326
        $source = \SimpleSAML\Auth\Source::getById($authId);
327
        if ($source === null) {
328
            throw new \Exception('Could not find authentication source with id ' . $state[self::AUTHID]);
329
        }
330
331
        try {
332
            $source->authenticate($state);
333
        } catch (\SimpleSAML\Error\Exception $e) {
334
            \SimpleSAML\Auth\State::throwException($state, $e);
335
        } catch (\Exception $e) {
336
            $e = new \SimpleSAML\Error\UnserializableException($e);
337
            \SimpleSAML\Auth\State::throwException($state, $e);
338
        }
339
        // fallBack never returns after loginCompleted()
340
        Logger::debug('Negotiate: backend returned');
341
        self::loginCompleted($state);
342
    }
343
344
345
    /**
346
     * Strips away the realm of the Kerberos identifier, looks up what attributes to fetch from SP metadata and
347
     * searches the directory.
348
     *
349
     * @param string $user The Kerberos user identifier.
350
     *
351
     * @return array|null The attributes for the user or NULL if not found.
352
     */
353
    protected function lookupUserData(string $user): ?array
354
    {
355
        // Kerberos user names include realm. Strip that away.
356
        $pos = strpos($user, '@');
357
        if ($pos === false) {
358
            return null;
359
        }
360
        $uid = substr($user, 0, $pos);
361
362
        $this->adminBind();
363
        try {
364
            /** @psalm-var string $dn */
365
            $dn = $this->ldap->searchfordn($this->base, $this->attr, $uid);
366
            return $this->ldap->getAttributes($dn, $this->attributes);
367
        } catch (\SimpleSAML\Error\Exception $e) {
368
            Logger::debug('Negotiate - ldap lookup failed: ' . $e);
369
            return null;
370
        }
371
    }
372
373
374
    /**
375
     * Elevates the LDAP connection to allow restricted lookups if
376
     * so configured. Does nothing if not.
377
     *
378
     * @return void
379
     * @throws \SimpleSAML\Error\AuthSource
380
     */
381
    protected function adminBind(): void
382
    {
383
        if ($this->admin_user === null || $this->admin_pw === null) {
384
            // no admin user
385
            return;
386
        }
387
        Logger::debug('Negotiate - authenticate(): Binding as system user ' . var_export($this->admin_user, true));
388
389
        if (!$this->ldap->bind($this->admin_user, $this->admin_pw)) {
390
            $msg = 'Unable to authenticate system user (LDAP_INVALID_CREDENTIALS) '
391
                . var_export($this->admin_user, true);
392
            Logger::error('Negotiate - authenticate(): ' . $msg);
393
            throw new \SimpleSAML\Error\AuthSource('negotiate', $msg);
394
        }
395
    }
396
397
398
    /**
399
     * Log out from this authentication source.
400
     *
401
     * This method either logs the user out from Negotiate or passes the
402
     * logout call to the fallback module.
403
     *
404
     * @param array &$state Information about the current logout operation.
405
     * @return void
406
     */
407
    public function logout(array &$state): void
408
    {
409
        // get the source that was used to authenticate
410
        $authId = $state['negotiate:backend'];
411
        Logger::debug('Negotiate - logout has the following authId: "' . $authId . '"');
412
413
        if ($authId === null) {
414
            $session = \SimpleSAML\Session::getSessionFromRequest();
415
            $session->setData('negotiate:disable', 'session', true, 24 * 60 * 60);
416
            parent::logout($state);
417
        } else {
418
            /** @psalm-var \SimpleSAML\Module\negotiate\Auth\Source\Negotiate|null $source */
419
            $source = \SimpleSAML\Auth\Source::getById($authId);
420
            if ($source === null) {
421
                throw new \Exception('Could not find authentication source with id ' . $state[self::AUTHID]);
422
            }
423
            $source->logout($state);
424
        }
425
    }
426
}
427