Negotiate::externalAuth()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

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

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