Passed
Push — master ( 6e8052...014453 )
by Tim
08:42 queued 06:02
created

Negotiate::checkMask()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 4
eloc 11
c 2
b 0
f 0
nc 4
nop 0
dl 0
loc 17
rs 9.9
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\negotiateext\Auth\Source;
6
7
use Exception;
8
use SimpleSAML\Assert\Assert;
9
use SimpleSAML\Auth;
10
use SimpleSAML\Error;
11
use SimpleSAML\Logger;
12
use SimpleSAML\Module;
13
use SimpleSAML\Module\ldap\Auth\Ldap;
14
use SimpleSAML\Session;
15
use SimpleSAML\Utils;
16
17
/**
18
 * The Negotiate module. Allows for password-less, secure login by Kerberos and Negotiate.
19
 *
20
 * @package simplesamlphp/simplesamlphp-module-negotiate
21
 */
22
23
class Negotiate extends \SimpleSAML\Auth\Source
24
{
25
    // Constants used in the module
26
    public const STAGEID = '\SimpleSAML\Module\negotiateext\Auth\Source\Negotiate.StageId';
27
28
    /** @var \SimpleSAML\Module\ldap\Auth\Ldap */
29
    protected Ldap $ldap;
30
31
    /** @var string */
32
    protected string $backend = '';
33
34
    /** @var string */
35
    protected string $hostname = '';
36
37
    /** @var int */
38
    protected int $port = 389;
39
40
    /** @var bool */
41
    protected bool $referrals = true;
42
43
    /** @var bool */
44
    protected bool $enableTLS = false;
45
46
    /** @var bool */
47
    protected bool $debugLDAP = false;
48
49
    /** @var int */
50
    protected int $timeout = 30;
51
52
    /** @var string */
53
    protected string $keytab = '';
54
55
    /** @var array */
56
    protected array $base = [];
57
58
    /** @var array */
59
    protected array $attr = ['uid'];
60
61
    /** @var array|null */
62
    protected ?array $subnet = null;
63
64
    /** @var string|null */
65
    protected ?string $admin_user = null;
66
67
    /** @var string|null */
68
    protected ?string $admin_pw = null;
69
70
    /** @var array|null */
71
    protected ?array $attributes = null;
72
73
    /** @var array */
74
    protected array $binaryAttributes = [];
75
76
77
    /**
78
     * Constructor for this authentication source.
79
     *
80
     * @param array $info Information about this authentication source.
81
     * @param array $config The configuration of the module
82
     */
83
    public function __construct(array $info, array $config)
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->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...
98
        $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...
99
        $this->subnet = $cfg->getArray('subnet', null);
100
        $this->admin_user = $cfg->getString('adminUser', null);
101
        $this->admin_pw = $cfg->getString('adminPassword', null);
102
        $this->attributes = $cfg->getArray('attributes', null);
103
        $this->binaryAttributes = $cfg->getArray('attributes.binary', []);
104
    }
105
106
107
    /**
108
     * The inner workings of the module.
109
     *
110
     * Checks to see if client is in the defined subnets (if defined in config). Sends the client a 401 Negotiate and
111
     * responds to the result. If the client fails to provide a proper Kerberos ticket, the login process is handed over
112
     * to the 'fallback' module defined in the config.
113
     *
114
     * LDAP is used as a user metadata source.
115
     *
116
     * @param array &$state Information about the current authentication.
117
     */
118
    public function authenticate(array &$state): void
119
    {
120
        // set the default backend to config
121
        $state['LogoutState'] = [
122
            'negotiate:backend' => $this->backend,
123
        ];
124
        $state['negotiate:authId'] = $this->authId;
125
126
127
        // check for disabled SPs. The disable flag is store in the SP metadata
128
        if (array_key_exists('SPMetadata', $state) && $this->spDisabledInMetadata($state['SPMetadata'])) {
129
            $this->fallBack($state);
130
        }
131
        /* Go straight to fallback if Negotiate is disabled or if you are sent back to the IdP directly from the SP
132
        after having logged out. */
133
        $session = \SimpleSAML\Session::getSessionFromRequest();
134
        $disabled = $session->getData('negotiate:disable', 'session');
135
136
        if (
137
            $disabled ||
138
            (!empty($_REQUEST['negotiateext_auth']) &&
139
                $_REQUEST['negotiateext_auth'] == 'false') ||
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
        // No auth token. Send it.
156
        Logger::debug('Negotiate - authenticate(): Sending Negotiate.');
157
        // Save the $state array, so that we can restore if after a redirect
158
        Logger::debug('Negotiate - fallback: ' . $state['LogoutState']['negotiate:backend']);
159
        $id = \SimpleSAML\Auth\State::saveState($state, self::STAGEID);
160
        $params = ['AuthState' => $id];
161
162
        $this->sendNegotiate($params);
163
        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...
164
    }
165
166
167
    /**
168
     * @param array $spMetadata
169
     * @return bool
170
     */
171
    public function spDisabledInMetadata(array $spMetadata): bool
172
    {
173
        if (array_key_exists('negotiate:disable', $spMetadata)) {
174
            if ($spMetadata['negotiate:disable'] == true) {
175
                Logger::debug('Negotiate - SP disabled. falling back');
176
                return true;
177
            } else {
178
                Logger::debug('Negotiate - SP disable flag found but set to FALSE');
179
            }
180
        } else {
181
            Logger::debug('Negotiate - SP disable flag not found');
182
        }
183
        return false;
184
    }
185
186
187
    /**
188
     * checkMask() looks up the subnet config option and verifies
189
     * that the client is within that range.
190
     *
191
     * Will return TRUE if no subnet option is configured.
192
     *
193
     * @return bool
194
     */
195
    public function checkMask(): bool
196
    {
197
        // No subnet means all clients are accepted.
198
        if ($this->subnet === null) {
199
            return true;
200
        }
201
        $ip = $_SERVER['REMOTE_ADDR'];
202
        $netUtils = new Utils\Net();
203
        foreach ($this->subnet as $cidr) {
204
            $ret = $netUtils->ipCIDRcheck($cidr);
205
            if ($ret) {
206
                Logger::debug('Negotiate: Client "' . $ip . '" matched subnet.');
207
                return true;
208
            }
209
        }
210
        Logger::debug('Negotiate: Client "' . $ip . '" did not match subnet.');
211
        return false;
212
    }
213
214
215
    /**
216
     * Send the actual headers and body of the 401. Embedded in the body is a post that is triggered by JS if the client
217
     * wants to show the 401 message.
218
     *
219
     * @param array $params additional parameters to the URL in the URL in the body.
220
     */
221
    protected function sendNegotiate(array $params): void
222
    {
223
        $authPage = Module::getModuleURL('negotiateext/auth.php');
224
        $httpUtils = new Utils\HTTP();
225
        $httpUtils->redirectTrustedURL($authPage, $params);
226
    }
227
228
229
    /**
230
     * Passes control of the login process to a different module.
231
     *
232
     * @param array $state Information about the current authentication.
233
     *
234
     * @throws \SimpleSAML\Error\Error If couldn't determine the auth source.
235
     * @throws \SimpleSAML\Error\Exception
236
     * @throws \Exception
237
     */
238
    public static function fallBack(array &$state): void
239
    {
240
        $authId = $state['LogoutState']['negotiate:backend'];
241
242
        if ($authId === null) {
243
            throw new Error\Error([500, "Unable to determine auth source."]);
244
        }
245
        Logger::debug('Negotiate: fallBack to ' . $authId);
246
        $source = Auth\Source::getById($authId);
247
248
        if ($source === null) {
249
            throw new Exception('Could not find authentication source with id ' . $authId);
250
        }
251
252
        try {
253
            $source->authenticate($state);
254
        } catch (Error\Exception $e) {
255
            Auth\State::throwException($state, $e);
256
        } catch (Exception $e) {
257
            $e = new Error\UnserializableException($e);
258
            Auth\State::throwException($state, $e);
259
        }
260
        // fallBack never returns after loginCompleted()
261
        Logger::debug('Negotiate: backend returned');
262
        self::loginCompleted($state);
263
    }
264
265
266
    /**
267
     * @param array $state Information about the current authentication.
268
     */
269
    public function externalAuth(array &$state): void
270
    {
271
        Logger::debug('Negotiate - authenticate(): remote user found');
272
        $this->ldap = new Ldap(
273
            $this->hostname,
274
            $this->enableTLS,
275
            $this->debugLDAP,
276
            $this->timeout,
277
            $this->port,
278
            $this->referrals
279
        );
280
281
        $user = $_SERVER['REMOTE_USER'];
282
        Logger::info('Negotiate - authenticate(): ' . $user . ' authenticated.');
283
        $lookup = $this->lookupUserData($user);
284
        if ($lookup) {
285
            $state['Attributes'] = $lookup;
286
            // Override the backend so logout will know what to look for
287
            $state['LogoutState'] = [
288
                'negotiate:backend' => null,
289
            ];
290
            Logger::info('Negotiate - authenticate(): ' . $user . ' authorized.');
291
            Auth\Source::completeAuth($state);
292
            // Never reached.
293
            assert(false);
294
        }
295
    }
296
297
298
    /**
299
     * Passes control of the login process to a different module.
300
     *
301
     * @param string $state Information about the current authentication.
302
     *
303
     * @throws \SimpleSAML\Error\BadRequest If couldn't determine the auth source.
304
     * @throws \SimpleSAML\Error\NoState
305
     * @throws \SimpleSAML\Error\Exception
306
     */
307
    public static function external(): void
308
    {
309
        if (!isset($_REQUEST['AuthState'])) {
310
            throw new Error\BadRequest('Missing "AuthState" parameter.');
311
        }
312
        Logger::debug('Negotiate: external returned');
313
        $sid = Auth\State::parseStateID($_REQUEST['AuthState']);
314
315
        $state = Auth\State::loadState($_REQUEST['AuthState'], self::STAGEID, true);
316
        if ($state === null) {
317
            if ($sid['url'] === null) {
318
                throw new Error\NoState();
319
            }
320
            $httpUtils = new Utils\HTTP();
321
            $httpUtils->redirectUntrustedURL($sid['url'], ['negotiateext.auth' => 'false']);
322
            assert(false);
323
        }
324
325
        Assert::isArray($state);
326
327
        if (!empty($_SERVER['REMOTE_USER'])) {
328
            $source = Auth\Source::getById($state['negotiate:authId']);
329
            if ($source === null) {
330
                /*
331
                 * The only way this should fail is if we remove or rename the authentication source
332
                 * while the user is at the login page.
333
                 */
334
                throw new Error\Exception(
335
                    'Could not find authentication source with id ' . $state['negotiate:authId']
336
                );
337
            }
338
            /*
339
             * Make sure that we haven't switched the source type while the
340
             * user was at the authentication page. This can only happen if we
341
             * change config/authsources.php while an user is logging in.
342
             */
343
            if (!($source instanceof self)) {
344
                throw new Error\Exception('Authentication source type changed.');
345
            }
346
            Logger::debug('Negotiate - authenticate(): looking for Negotate');
347
            $source->externalAuth($state);
348
        }
349
350
        self::fallBack($state);
351
        assert(false);
352
    }
353
354
355
    /**
356
     * Strips away the realm of the Kerberos identifier, looks up what attributes to fetch from SP metadata and
357
     * searches the directory.
358
     *
359
     * @param string $user The Kerberos user identifier.
360
     *
361
     * @return array|null The attributes for the user or NULL if not found.
362
     */
363
    protected function lookupUserData(string $user): ?array
364
    {
365
        // Kerberos user names include realm. Strip that away.
366
        $pos = strpos($user, '@');
367
        if ($pos === false) {
368
            return null;
369
        }
370
        $uid = substr($user, 0, $pos);
371
372
        $this->adminBind();
373
        try {
374
            /** @psalm-var string $dn */
375
            $dn = $this->ldap->searchfordn($this->base, $this->attr, $uid);
376
            return $this->ldap->getAttributes($dn, $this->attributes, $this->binaryAttributes);
377
        } catch (Error\Exception $e) {
378
            Logger::debug('Negotiate - ldap lookup failed: ' . $e);
379
            return null;
380
        }
381
    }
382
383
384
    /**
385
     * Elevates the LDAP connection to allow restricted lookups if
386
     * so configured. Does nothing if not.
387
     *
388
     * @throws \SimpleSAML\Error\AuthSource
389
     */
390
    protected function adminBind(): void
391
    {
392
        if ($this->admin_user === null || $this->admin_pw === null) {
393
            // no admin user
394
            return;
395
        }
396
        Logger::debug('Negotiate - authenticate(): Binding as system user ' . var_export($this->admin_user, true));
397
398
        if (!$this->ldap->bind($this->admin_user, $this->admin_pw)) {
399
            $msg = 'Unable to authenticate system user (LDAP_INVALID_CREDENTIALS)';
400
            Logger::error('Negotiate - authenticate(): ' . $msg . ' ' . var_export($this->admin_user, true));
401
            throw new Error\AuthSource('negotiate', $msg);
402
        }
403
    }
404
405
406
    /**
407
     * Log out from this authentication source.
408
     *
409
     * This method either logs the user out from Negotiate or passes the
410
     * logout call to the fallback module.
411
     *
412
     * @param array &$state Information about the current logout operation.
413
     */
414
    public function logout(array &$state): void
415
    {
416
        // get the source that was used to authenticate
417
        $authId = $state['negotiate:backend'];
418
        Logger::debug('Negotiate - logout has the following authId: "' . $authId . '"');
419
420
        if ($authId === null) {
421
            $session = Session::getSessionFromRequest();
422
            $session->setData('negotiate:disable', 'session', true, 0);
423
            parent::logout($state);
424
        } else {
425
            $source = Auth\Source::getById($authId);
426
            if ($source === null) {
427
                throw new \Exception('Could not find authentication source with id ' . $authId);
428
            }
429
430
            $source->logout($state);
431
        }
432
    }
433
}
434