Passed
Pull Request — master (#5)
by Tim
02:42
created

Negotiate::adminBind()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 8
nc 3
nop 0
dl 0
loc 13
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\negotiate\Auth\Source;
6
7
use Exception;
8
use KRB5NegotiateAuth;
9
use SimpleSAML\Assert\Assert;
10
use SimpleSAML\Auth;
11
use SimpleSAML\Configuration;
12
use SimpleSAML\Error;
13
use SimpleSAML\Logger;
14
use SimpleSAML\Module;
15
use SimpleSAML\Session;
16
use SimpleSAML\Utils;
17
use SimpleSAML\XHTML\Template;
18
19
/**
20
 * The Negotiate module. Allows for password-less, secure login by Kerberos and Negotiate.
21
 *
22
 * @package simplesamlphp/simplesamlphp-module-negotiate
23
 */
24
class Negotiate extends Auth\Source
25
{
26
    // Constants used in the module
27
    public const STAGEID = '\SimpleSAML\Module\negotiate\Auth\Source\Negotiate.StageId';
28
29
    public const AUTHID = '\SimpleSAML\Module\negotiate\Auth\Source\Negotiate.AuthId';
30
31
    /** @var string */
32
    protected string $backend;
33
34
    /** @var string */
35
    protected string $fallback;
36
37
    /** @var string */
38
    protected string $keytab;
39
40
    /** @var string|integer|null */
41
    protected $spn = null;
42
43
    /** @var array|null */
44
    protected ?array $subnet = null;
45
46
47
    /**
48
     * Constructor for this authentication source.
49
     *
50
     * @param array $info Information about this authentication source.
51
     * @param array $config The configuration of the module
52
     *
53
     * @throws \Exception If the KRB5 extension is not installed or active.
54
     */
55
    public function __construct(array $info, array $config)
56
    {
57
        if (!extension_loaded('krb5')) {
58
            throw new Exception('KRB5 Extension not installed');
59
        }
60
        // call the parent constructor first, as required by the interface
61
        parent::__construct($info, $config);
62
63
        $cfg = Configuration::loadFromArray($config);
64
        $this->backend = $cfg->getString('backend');
65
        $this->fallback = $cfg->getOptionalString('fallback', $this->backend);
66
        $this->spn = $cfg->getOptionalValue('spn', null);
67
        $configUtils = new Utils\Config();
68
        $this->keytab = $configUtils->getCertPath($cfg->getString('keytab'));
69
        $this->subnet = $cfg->getOptionalArray('subnet', null);
70
    }
71
72
73
    /**
74
     * The inner workings of the module.
75
     *
76
     * Checks to see if client is in the defined subnets (if defined in config). Sends the client a 401 Negotiate and
77
     * responds to the result. If the client fails to provide a proper Kerberos ticket, the login process is handed over
78
     * to the 'fallback' module defined in the config.
79
     *
80
     * LDAP is used as a user metadata source.
81
     *
82
     * @param array &$state Information about the current authentication.
83
     */
84
    public function authenticate(array &$state): void
85
    {
86
        // set the default backend to config
87
        $state['LogoutState'] = [
88
            'negotiate:backend' => $this->backend,
89
        ];
90
        $state['negotiate:authId'] = $this->authId;
91
        $state['negotiate:fallback'] = $this->fallback;
92
93
94
        // check for disabled SPs. The disable flag is stored in the SP metadata
95
        if (array_key_exists('SPMetadata', $state) && $this->spDisabledInMetadata($state['SPMetadata'])) {
96
            $this->fallBack($state);
97
        }
98
        /* Go straight to fallback if Negotiate is disabled or if you are sent back to the IdP directly from the SP
99
        after having logged out. */
100
        $session = Session::getSessionFromRequest();
101
        $disabled = $session->getData('negotiate:disable', 'session');
102
103
        if (
104
            $disabled
105
            || (!empty($_COOKIE['NEGOTIATE_AUTOLOGIN_DISABLE_PERMANENT'])
106
                && $_COOKIE['NEGOTIATE_AUTOLOGIN_DISABLE_PERMANENT'] === 'true')
107
        ) {
108
            Logger::debug('Negotiate - session disabled. falling back');
109
            $this->fallBack($state);
110
            // never executed
111
            assert(false);
112
        }
113
        $mask = $this->checkMask();
114
        if (!$mask) {
115
            $this->fallBack($state);
116
            // never executed
117
            assert(false);
118
        }
119
120
        Logger::debug('Negotiate - authenticate(): looking for Negotiate');
121
        if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
122
            Logger::debug('Negotiate - authenticate(): Negotiate found');
123
            list($mech,) = explode(' ', $_SERVER['HTTP_AUTHORIZATION'], 2);
124
            if (strtolower($mech) === 'basic') {
125
                Logger::debug('Negotiate - authenticate(): Basic found. Skipping.');
126
            } elseif (strtolower($mech) !== 'negotiate') {
127
                Logger::debug('Negotiate - authenticate(): No "Negotiate" found. Skipping.');
128
            }
129
130
            Assert::true(is_string($this->spn) || (is_int($this->spn) && ($this->spn === 0)) || is_null($this->spn));
131
            $auth = new KRB5NegotiateAuth($this->keytab, $this->spn);
132
133
            // attempt Kerberos authentication
134
            try {
135
                $reply = $auth->doAuthentication();
136
            } catch (Exception $e) {
137
                Logger::error('Negotiate - authenticate(): doAuthentication() exception: ' . $e->getMessage());
138
                $reply = null;
139
            }
140
141
            if ($reply) {
142
                // success! krb TGS received
143
                $user = $auth->getAuthenticatedUser();
144
                Logger::info('Negotiate - authenticate(): ' . $user . ' authenticated.');
145
                $lookup = $this->lookupUserData($user);
146
                if ($lookup !== null) {
147
                    $state['Attributes'] = $lookup;
148
                    // Override the backend so logout will know what to look for
149
                    $state['LogoutState'] = [
150
                        'negotiate:backend' => null,
151
                    ];
152
                    Logger::info('Negotiate - authenticate(): ' . $user . ' authorized.');
153
                    Auth\Source::completeAuth($state);
154
                    // Never reached.
155
                    assert(false);
156
                }
157
            } else {
158
                // Some error in the received ticket. Expired?
159
                Logger::info('Negotiate - authenticate(): Kerberos authN failed. Skipping.');
160
            }
161
        } else {
162
            // No auth token. Send it.
163
            Logger::debug('Negotiate - authenticate(): Sending Negotiate.');
164
            // Save the $state array, so that we can restore if after a redirect
165
            Logger::debug('Negotiate - fallback: ' . $state['LogoutState']['negotiate:backend']);
166
            $id = Auth\State::saveState($state, self::STAGEID);
167
            $params = ['AuthState' => $id];
168
169
            $this->sendNegotiate($params);
170
            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...
171
        }
172
173
        Logger::info('Negotiate - authenticate(): Client failed Negotiate. Falling back');
174
        $this->fallBack($state);
175
        // The previous function never returns, so this code is never executed
176
        assert(false);
177
    }
178
179
180
    /**
181
     * @param array $spMetadata
182
     * @return bool
183
     */
184
    public function spDisabledInMetadata(array $spMetadata): bool
185
    {
186
        if (array_key_exists('negotiate:disable', $spMetadata)) {
187
            if ($spMetadata['negotiate:disable'] == true) {
188
                Logger::debug('Negotiate - SP disabled. falling back');
189
                return true;
190
            } else {
191
                Logger::debug('Negotiate - SP disable flag found but set to FALSE');
192
            }
193
        } else {
194
            Logger::debug('Negotiate - SP disable flag not found');
195
        }
196
        return false;
197
    }
198
199
200
    /**
201
     * checkMask() looks up the subnet config option and verifies
202
     * that the client is within that range.
203
     *
204
     * Will return TRUE if no subnet option is configured.
205
     *
206
     * @return bool
207
     */
208
    public function checkMask(): bool
209
    {
210
        // No subnet means all clients are accepted.
211
        if ($this->subnet === null) {
212
            return true;
213
        }
214
        $ip = $_SERVER['REMOTE_ADDR'];
215
        $netUtils = new Utils\Net();
216
        foreach ($this->subnet as $cidr) {
217
            $ret = $netUtils->ipCIDRcheck($cidr);
218
            if ($ret) {
219
                Logger::debug('Negotiate: Client "' . $ip . '" matched subnet.');
220
                return true;
221
            }
222
        }
223
        Logger::debug('Negotiate: Client "' . $ip . '" did not match subnet.');
224
        return false;
225
    }
226
227
228
    /**
229
     * Send the actual headers and body of the 401. Embedded in the body is a post that is triggered by JS if the client
230
     * wants to show the 401 message.
231
     *
232
     * @param array $params additional parameters to the URL in the URL in the body.
233
     */
234
    protected function sendNegotiate(array $params): void
235
    {
236
        $config = Configuration::getInstance();
237
238
        $url = htmlspecialchars(Module::getModuleURL('negotiate/backend', $params));
239
240
        $t = new Template($config, 'negotiate:redirect.twig');
241
        $t->setStatusCode(401);
242
        $t->headers->set('WWW-Authenticate', 'Negotiate');
243
        $t->data['baseurlpath'] = Module::getModuleURL('negotiate');
244
        $t->data['url'] = $url;
245
        $t->send();
246
    }
247
248
249
    /**
250
     * Passes control of the login process to a different module.
251
     *
252
     * @param array $state Information about the current authentication.
253
     *
254
     * @throws \SimpleSAML\Error\Error If couldn't determine the auth source.
255
     * @throws \SimpleSAML\Error\Exception
256
     * @throws \Exception
257
     */
258
    public static function fallBack(array &$state): void // never
259
    {
260
        $authId = $state['negotiate:fallback'];
261
        if ($authId === null) {
262
            throw new Error\Error([500, "Unable to determine auth source."]);
263
        }
264
265
        /** @psalm-var \SimpleSAML\Module\negotiate\Auth\Source\Negotiate|null $source */
266
        $source = Auth\Source::getById($authId);
267
        if ($source === null) {
268
            throw new Exception('Could not find authentication source with id ' . $state[self::AUTHID]);
269
        }
270
271
        try {
272
            $source->authenticate($state);
273
        } catch (Error\Exception $e) {
274
            Auth\State::throwException($state, $e);
275
        } catch (Exception $e) {
276
            $e = new Error\UnserializableException($e);
277
            Auth\State::throwException($state, $e);
278
        }
279
280
        // fallBack never returns after loginCompleted()
281
        Logger::debug('Negotiate: backend returned');
282
        self::loginCompleted($state);
283
    }
284
285
286
    /**
287
     * Strips away the realm of the Kerberos identifier, looks up what attributes to fetch from SP metadata and
288
     * searches the directory.
289
     *
290
     * @param string $user The Kerberos user identifier.
291
     *
292
     * @return array|null The attributes for the user or NULL if not found.
293
     */
294
    protected function lookupUserData(string $user): ?array
295
    {
296
        // Kerberos user names include realm. Strip that away.
297
        $pos = strpos($user, '@');
298
        if ($pos === false) {
299
            return null;
300
        }
301
        $uid = substr($user, 0, $pos);
302
303
        /** @psalm-var \SimpleSAML\Module\ldap\Auth\Source\Ldap|null $source */
304
        $source = Auth\Source::getById($this->backend);
305
        if ($source === null) {
306
            throw new Exception('Could not find authentication source with id ' . $this->backend);
307
        }
308
309
        try {
310
            return $source->getAttributes($uid);
0 ignored issues
show
Bug introduced by
The method getAttributes() does not exist on SimpleSAML\Auth\Source. It seems like you code against a sub-type of SimpleSAML\Auth\Source such as SimpleSAML\Module\ldap\Auth\Source\Ldap. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

310
            return $source->/** @scrutinizer ignore-call */ getAttributes($uid);
Loading history...
311
        } catch (Error\Exception $e) {
312
            Logger::debug('Negotiate - ldap lookup failed: ' . $e);
313
            return null;
314
        }
315
    }
316
317
318
    /**
319
     * Log out from this authentication source.
320
     *
321
     * This method either logs the user out from Negotiate or passes the
322
     * logout call to the fallback module.
323
     *
324
     * @param array &$state Information about the current logout operation.
325
     */
326
    public function logout(array &$state): void
327
    {
328
        // get the source that was used to authenticate
329
        $authId = $state['LogoutState']['negotiate:backend'];
330
        Logger::debug('Negotiate - logout has the following authId: "' . $authId . '"');
331
332
        if ($authId === null) {
333
            $session = Session::getSessionFromRequest();
334
            $session->setData('negotiate:disable', 'session', true, 24 * 60 * 60);
335
            parent::logout($state);
336
        } else {
337
            /** @psalm-var \SimpleSAML\Module\negotiate\Auth\Source\Negotiate|null $source */
338
            $source = Auth\Source::getById($authId);
339
            if ($source === null) {
340
                throw new \Exception('Could not find authentication source with id ' . $state[self::AUTHID]);
341
            }
342
            $source->logout($state);
343
        }
344
    }
345
}
346