Negotiate   A
last analyzed

Complexity

Total Complexity 38

Size/Duplication

Total Lines 327
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 38
eloc 126
c 2
b 0
f 0
dl 0
loc 327
rs 9.36

10 Methods

Rating   Name   Duplication   Size   Complexity  
B authenticate() 0 43 9
A spDisabledInMetadata() 0 13 3
A lookupUserData() 0 20 4
A checkMask() 0 17 3
A __construct() 0 10 1
A sendNegotiate() 0 5 1
B external() 0 43 7
A logout() 0 17 3
A fallBack() 0 26 5
A externalAuth() 0 15 2
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\Session;
14
use SimpleSAML\Utils;
15
use Symfony\Component\HttpFoundation\IpUtils;
16
use Symfony\Component\HttpFoundation\Request;
17
18
/**
19
 * The Negotiate module. Allows for password-less, secure login by Kerberos and Negotiate.
20
 *
21
 * @package simplesamlphp/simplesamlphp-module-negotiate
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
29
    /** @var string */
30
    protected string $backend;
31
32
    /** @var string */
33
    protected string $fallback;
34
35
    /** @var string */
36
    protected string $keytab = '';
37
38
    /** @var array|null */
39
    protected ?array $subnet = null;
40
41
42
    /**
43
     * Constructor for this authentication source.
44
     *
45
     * @param array $info Information about this authentication source.
46
     * @param array $config The configuration of the module
47
     */
48
    public function __construct(array $info, array $config)
49
    {
50
        // call the parent constructor first, as required by the interface
51
        parent::__construct($info, $config);
52
53
        $cfg = \SimpleSAML\Configuration::loadFromArray($config);
54
55
        $this->backend = $cfg->getString('backend');
56
        $this->fallback = $cfg->getString('fallback');
57
        $this->subnet = $cfg->getOptionalArray('subnet', null);
58
    }
59
60
61
    /**
62
     * The inner workings of the module.
63
     *
64
     * Checks to see if client is in the defined subnets (if defined in config). Sends the client a 401 Negotiate and
65
     * responds to the result. If the client fails to provide a proper Kerberos ticket, the login process is handed over
66
     * to the 'fallback' module defined in the config.
67
     *
68
     * LDAP is used as a user metadata source.
69
     *
70
     * @param array &$state Information about the current authentication.
71
     */
72
    public function authenticate(array &$state): never
73
    {
74
        // set the default backend to config
75
        $state['LogoutState'] = [
76
            'negotiate:backend' => $this->backend,
77
        ];
78
        $state['negotiate:authId'] = $this->authId;
79
        $state['negotiate:fallback'] = $this->fallback;
80
81
82
        // check for disabled SPs. The disable flag is store in the SP metadata
83
        if (array_key_exists('SPMetadata', $state) && $this->spDisabledInMetadata($state['SPMetadata'])) {
84
            $this->fallBack($state);
85
        }
86
        /* Go straight to fallback if Negotiate is disabled or if you are sent back to the IdP directly from the SP
87
        after having logged out. */
88
        $session = Session::getSessionFromRequest();
89
        $disabled = $session->getData('negotiate:disable', 'session');
90
91
        if (
92
            $disabled ||
93
            (!empty($_REQUEST['negotiateext_auth']) &&
94
                $_REQUEST['negotiateext_auth'] === 'false') ||
95
            (!empty($_COOKIE['NEGOTIATE_AUTOLOGIN_DISABLE_PERMANENT']) &&
96
                $_COOKIE['NEGOTIATE_AUTOLOGIN_DISABLE_PERMANENT'] === 'true')
97
        ) {
98
            Logger::debug('Negotiate - session disabled. falling back');
99
            $this->fallBack($state);
100
        }
101
102
        if (!$this->checkMask()) {
103
            $this->fallBack($state);
104
        }
105
106
        // No auth token. Send it.
107
        Logger::debug('Negotiate - authenticate(): Sending Negotiate.');
108
        // Save the $state array, so that we can restore if after a redirect
109
        Logger::debug('Negotiate - fallback: ' . $state['LogoutState']['negotiate:backend']);
110
        $id = Auth\State::saveState($state, self::STAGEID);
111
        $params = ['AuthState' => $id];
112
113
        $this->sendNegotiate($params);
114
        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...
115
    }
116
117
118
    /**
119
     * @param array $spMetadata
120
     * @return bool
121
     */
122
    public function spDisabledInMetadata(array $spMetadata): bool
123
    {
124
        if (array_key_exists('negotiate:disable', $spMetadata)) {
125
            if ($spMetadata['negotiate:disable'] === true) {
126
                Logger::debug('Negotiate - SP disabled. falling back');
127
                return true;
128
            } else {
129
                Logger::debug('Negotiate - SP disable flag found but set to FALSE');
130
            }
131
        } else {
132
            Logger::debug('Negotiate - SP disable flag not found');
133
        }
134
        return false;
135
    }
136
137
138
    /**
139
     * checkMask() looks up the subnet config option and verifies
140
     * that the client is within that range.
141
     *
142
     * Will return TRUE if no subnet option is configured.
143
     *
144
     * @return bool
145
     */
146
    public function checkMask(): bool
147
    {
148
        // No subnet means all clients are accepted.
149
        if ($this->subnet === null) {
150
            return true;
151
        }
152
153
        $ip = Request::createFromGlobals()->getClientIp();
154
        Assert::notNull($ip, "Unable to determine client IP.");
155
156
        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

156
        if (IpUtils::checkIp(/** @scrutinizer ignore-type */ $ip, $this->subnet)) {
Loading history...
157
            Logger::debug('Negotiate: Client "' . $ip . '" matched subnet.');
158
            return true;
159
        }
160
161
        Logger::debug('Negotiate: Client "' . $ip . '" did not match subnet.');
162
        return false;
163
    }
164
165
166
    /**
167
     * Send the actual headers and body of the 401. Embedded in the body is a post that is triggered by JS if the client
168
     * wants to show the 401 message.
169
     *
170
     * @param array $params additional parameters to the URL in the URL in the body.
171
     */
172
    protected function sendNegotiate(array $params): void
173
    {
174
        $authPage = Module::getModuleURL('negotiateext/auth');
175
        $httpUtils = new Utils\HTTP();
176
        $httpUtils->redirectTrustedURL($authPage, $params);
177
    }
178
179
180
    /**
181
     * Passes control of the login process to a different module.
182
     *
183
     * @param array $state Information about the current authentication.
184
     *
185
     * @throws \SimpleSAML\Error\Error If couldn't determine the auth source.
186
     * @throws \SimpleSAML\Error\Exception
187
     * @throws \Exception
188
     */
189
    public static function fallBack(array &$state): void // never
190
    {
191
        $authId = $state['negotiate:fallback'];
192
193
        if ($authId === null) {
194
            throw new Error\Error([500, "Unable to determine auth source."]);
195
        }
196
        Logger::debug('Negotiate: fallBack to ' . $authId);
197
        $source = Auth\Source::getById($authId);
198
199
        if ($source === null) {
200
            throw new Exception('Could not find authentication source with id ' . $authId);
201
        }
202
203
        try {
204
            $source->authenticate($state);
205
        } catch (Error\Exception $e) {
206
            Auth\State::throwException($state, $e);
207
        } catch (Exception $e) {
208
            $e = new Error\UnserializableException($e);
209
            Auth\State::throwException($state, $e);
210
        }
211
212
        // fallBack never returns after loginCompleted()
213
        Logger::debug('Negotiate: backend returned');
214
        self::loginCompleted($state);
215
    }
216
217
218
    /**
219
     * @param array $state Information about the current authentication.
220
     */
221
    public function externalAuth(array &$state): void
222
    {
223
        Logger::debug('Negotiate - authenticate(): remote user found');
224
225
        $user = $_SERVER['REMOTE_USER'];
226
        Logger::info('Negotiate - authenticate(): ' . $user . ' authenticated.');
227
        $lookup = $this->lookupUserData($user);
228
        if ($lookup) {
229
            $state['Attributes'] = $lookup;
230
            // Override the backend so logout will know what to look for
231
            $state['LogoutState'] = [
232
                'negotiate:backend' => null,
233
            ];
234
            Logger::info('Negotiate - authenticate(): ' . $user . ' authorized.');
235
            Auth\Source::completeAuth($state);
236
        }
237
    }
238
239
240
    /**
241
     * Passes control of the login process to a different module.
242
     *
243
     * @throws \SimpleSAML\Error\BadRequest If couldn't determine the auth source.
244
     * @throws \SimpleSAML\Error\NoState
245
     * @throws \SimpleSAML\Error\Exception
246
     */
247
    public static function external(): void
248
    {
249
        if (!isset($_REQUEST['AuthState'])) {
250
            throw new Error\BadRequest('Missing "AuthState" parameter.');
251
        }
252
        Logger::debug('Negotiate: external returned');
253
        $sid = Auth\State::parseStateID($_REQUEST['AuthState']);
254
255
        $state = Auth\State::loadState($_REQUEST['AuthState'], self::STAGEID, true);
256
        if ($state === null) {
257
            if ($sid['url'] === null) {
258
                throw new Error\NoState();
259
            }
260
            $httpUtils = new Utils\HTTP();
261
            $httpUtils->redirectUntrustedURL($sid['url'], ['negotiateext.auth' => 'false']);
262
        }
263
264
        Assert::isArray($state);
265
266
        if (!empty($_SERVER['REMOTE_USER'])) {
267
            $source = Auth\Source::getById($state['negotiate:authId']);
268
            if ($source === null) {
269
                /*
270
                 * The only way this should fail is if we remove or rename the authentication source
271
                 * while the user is at the login page.
272
                 */
273
                throw new Error\Exception(
274
                    'Could not find authentication source with id ' . $state['negotiate:authId'],
275
                );
276
            }
277
            /*
278
             * Make sure that we haven't switched the source type while the
279
             * user was at the authentication page. This can only happen if we
280
             * change config/authsources.php while an user is logging in.
281
             */
282
            if (!($source instanceof self)) {
283
                throw new Error\Exception('Authentication source type changed.');
284
            }
285
            Logger::debug('Negotiate - authenticate(): looking for Negotate');
286
            $source->externalAuth($state);
287
        }
288
289
        self::fallBack($state);
290
    }
291
292
293
    /**
294
     * Strips away the realm of the Kerberos identifier, looks up what attributes to fetch from SP metadata and
295
     * searches the directory.
296
     *
297
     * @param string $user The Kerberos user identifier.
298
     *
299
     * @return array|null The attributes for the user or NULL if not found.
300
     */
301
    protected function lookupUserData(string $user): ?array
302
    {
303
        // Kerberos user names include realm. Strip that away.
304
        $pos = strpos($user, '@');
305
        if ($pos === false) {
306
            return null;
307
        }
308
        $uid = substr($user, 0, $pos);
309
310
        /** @var \SimpleSAML\Module\ldap\Auth\Source\Ldap|null $source */
311
        $source = Auth\Source::getById($this->backend);
312
        if ($source === null) {
313
            throw new Exception('Could not find authentication source with id ' . $this->backend);
314
        }
315
316
        try {
317
            return $source->getAttributes($uid);
318
        } catch (Error\Exception $e) {
319
            Logger::debug('Negotiate - ldap lookup failed: ' . $e);
320
            return null;
321
        }
322
    }
323
324
325
    /**
326
     * Log out from this authentication source.
327
     *
328
     * This method either logs the user out from Negotiate or passes the
329
     * logout call to the fallback module.
330
     *
331
     * @param array &$state Information about the current logout operation.
332
     */
333
    public function logout(array &$state): void
334
    {
335
        // get the source that was used to authenticate
336
        $authId = $state['negotiate:backend'];
337
        Logger::debug('Negotiate - logout has the following authId: "' . $authId . '"');
338
339
        if ($authId === null) {
340
            $session = Session::getSessionFromRequest();
341
            $session->setData('negotiate:disable', 'session', true, 0);
342
            parent::logout($state);
343
        } else {
344
            $source = Auth\Source::getById($authId);
345
            if ($source === null) {
346
                throw new Exception('Could not find authentication source with id ' . $authId);
347
            }
348
349
            $source->logout($state);
350
        }
351
    }
352
}
353