Passed
Push — master ( 9daad1...6e8052 )
by Tim
07:07 queued 04:24
created

Negotiate::sendNegotiate()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 2
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 4
rs 10
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');
98
        $this->attr = $cfg->getArrayizeString('attr', 'uid');
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;
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) {
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected T_FOREACH on line 203 at column 8
Loading history...
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