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