X509userCert::findUserByAttribute()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 24
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 3
eloc 14
c 3
b 0
f 0
nc 3
nop 2
dl 0
loc 24
rs 9.7998
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\authX509\Auth\Source;
6
7
use Exception;
8
use SimpleSAML\Assert\Assert;
9
use SimpleSAML\Auth;
10
use SimpleSAML\Configuration;
11
use SimpleSAML\Error;
12
use SimpleSAML\Logger;
13
use SimpleSAML\Module\ldap\ConnectorFactory;
14
use SimpleSAML\Module\ldap\ConnectorInterface;
15
use SimpleSAML\Utils;
16
use SimpleSAML\XHTML\Template;
17
use Symfony\Component\Ldap\Entry;
18
use Symfony\Component\Ldap\Ldap;
19
use Symfony\Component\Ldap\Security\LdapUserProvider;
20
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
21
22
use function array_fill_keys;
23
use function array_key_exists;
24
use function array_merge;
25
use function array_values;
26
use function current;
27
use function is_null;
28
use function openssl_x509_parse;
29
use function sprintf;
30
31
/**
32
 * This class implements x509 certificate authentication with certificate validation against an LDAP directory.
33
 *
34
 * @package SimpleSAMLphp
35
 */
36
37
class X509userCert extends Auth\Source
38
{
39
    /** @var \SimpleSAML\Module\ldap\ConnectorInterface */
40
    protected ConnectorInterface $connector;
41
42
    /**
43
     * The ldap-authsource to use
44
     * @var string
45
     */
46
    private string $backend;
47
48
    /**
49
     * The ldap-authsource config to use
50
     * @var \SimpleSAML\Configuration
51
     */
52
    private Configuration $ldapConfig;
53
54
    /**
55
     * x509 attributes to use from the certificate for searching the user in the LDAP directory.
56
     * @var array<string, string>
57
     */
58
    private array $x509attributes = ['UID' => 'uid'];
59
60
    /**
61
     * LDAP attribute containing the user certificate.
62
     * This can be set to NULL to avoid looking up the certificate in LDAP
63
     * @var array|null
64
     */
65
    private ?array $ldapusercert = ['userCertificate;binary'];
66
67
68
    /**
69
     * Constructor for this authentication source.
70
     *
71
     * All subclasses who implement their own constructor must call this constructor before using $config for anything.
72
     *
73
     * @param array $info Information about this authentication source.
74
     * @param array &$config Configuration for this authentication source.
75
     */
76
    public function __construct(array $info, array &$config)
77
    {
78
        parent::__construct($info, $config);
79
80
        if (isset($config['authX509:x509attributes'])) {
81
            $this->x509attributes = $config['authX509:x509attributes'];
82
        }
83
84
        if (array_key_exists('authX509:ldapusercert', $config)) {
85
            $this->ldapusercert = $config['authX509:ldapusercert'];
86
        }
87
88
        Assert::keyExists($config, 'backend');
89
        $this->backend = $config['backend'];
90
91
        // Get the authsources file, which should contain the backend-config
92
        $authSources = Configuration::getConfig('authsources.php');
93
94
        // Verify that the authsource config exists
95
        if (!$authSources->hasValue($this->backend)) {
96
            throw new Error\Exception(
97
                sprintf('Authsource [%s] not found in authsources.php', $this->backend),
98
            );
99
        }
100
101
        // Get just the specified authsource config values
102
        $this->ldapConfig = $authSources->getConfigItem($this->backend);
103
        $type = current($this->ldapConfig->toArray());
104
        Assert::oneOf($type, ['ldap:Ldap']);
105
106
        $this->connector = ConnectorFactory::fromAuthSource($this->backend);
107
    }
108
109
110
    /**
111
     * Finish a failed authentication.
112
     *
113
     * This function can be overloaded by a child authentication class that wish to perform some operations on failure.
114
     *
115
     * @param array &$state Information about the current authentication.
116
     */
117
    public function authFailed(&$state): void
118
    {
119
        $config = Configuration::getInstance();
120
        $errorcode = $state['authX509.error'];
121
        $errorcodes = (new Error\ErrorCodes())->getAllMessages();
122
123
        $t = new Template($config, 'authX509:X509error.twig');
124
        $httpUtils = new Utils\HTTP();
125
        $t->data['loginurl'] = $httpUtils->getSelfURL();
126
127
        if (!empty($errorcode)) {
128
            if (array_key_exists($errorcode, $errorcodes['title'])) {
129
                $t->data['errortitle'] = $errorcodes['title'][$errorcode];
130
            }
131
            if (array_key_exists($errorcode, $errorcodes['descr'])) {
132
                $t->data['errordescr'] = $errorcodes['descr'][$errorcode];
133
            }
134
        }
135
136
        $t->send();
137
        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...
138
    }
139
140
141
    /**
142
     * Validate certificate and login.
143
     *
144
     * This function try to validate the certificate. On success, the user is logged in without going through the login
145
     * page. On failure, The authX509:X509error.php template is loaded.
146
     *
147
     * @param array &$state Information about the current authentication.
148
     */
149
    public function authenticate(array &$state): void
150
    {
151
        if (
152
            !isset($_SERVER['SSL_CLIENT_CERT']) ||
153
            ($_SERVER['SSL_CLIENT_CERT'] == '')
154
        ) {
155
            $state['authX509.error'] = "NOCERT";
156
            $this->authFailed($state);
157
158
            throw new Exception("Should never be reached");
159
        }
160
161
        $client_cert = $_SERVER['SSL_CLIENT_CERT'];
162
        $client_cert_data = openssl_x509_parse($client_cert);
163
        if ($client_cert_data === false) {
164
            Logger::error('authX509: invalid cert');
165
            $state['authX509.error'] = "INVALIDCERT";
166
            $this->authFailed($state);
167
168
            throw new Exception("Should never be reached");
169
        }
170
171
        $entry = $dn = null;
172
        foreach ($this->x509attributes as $x509_attr => $attr) {
173
            // value is scalar
174
            if (array_key_exists($x509_attr, $client_cert_data['subject'])) {
175
                $value = $client_cert_data['subject'][$x509_attr];
176
                Logger::info('authX509: cert ' . $x509_attr . ' = ' . $value);
177
                $entry = $this->findUserByAttribute($attr, $value);
178
                if ($entry !== null) {
179
                    $dn = $attr;
180
                    break;
181
                }
182
            }
183
        }
184
185
        if ($entry === null) {
0 ignored issues
show
introduced by
The condition $entry === null is always true.
Loading history...
186
            Logger::error('authX509: cert has no matching user in LDAP.');
187
            $state['authX509.error'] = "UNKNOWNCERT";
188
            $this->authFailed($state);
189
190
            throw new Exception("Should never be reached");
191
        }
192
193
        if ($this->ldapusercert === null) {
194
            // do not check for certificate match
195
            if (is_null($this->ldapConfig->getOptionalArray('attributes', null))) {
196
                $attributes = $entry->getAttributes();
197
            } else {
198
                $attributes = array_intersect_key(
199
                    $entry->getAttributes(),
200
                    array_fill_keys(array_values($this->ldapConfig->getArray('attributes')), null),
201
                );
202
            }
203
204
            $state['Attributes'] = $attributes;
205
            $this->authSuccesful($state);
206
207
            throw new Exception("Should never be reached");
208
        }
209
210
        $ldap_certs = [];
211
        foreach ($this->ldapusercert as $attr) {
212
            $ldap_certs[$attr] = $entry->getAttribute($attr);
213
        }
214
215
        if (empty($ldap_certs)) {
216
            Logger::error('authX509: no certificate found in LDAP for dn=' . $dn);
217
            $state['authX509.error'] = "UNKNOWNCERT";
218
            $this->authFailed($state);
219
220
            throw new Exception("Should never be reached");
221
        }
222
223
224
        $merged_ldapcerts = [];
225
        foreach ($this->ldapusercert as $attr) {
226
            $merged_ldapcerts = array_merge($merged_ldapcerts, $ldap_certs[$attr]);
227
        }
228
        $ldap_certs = $merged_ldapcerts;
229
230
        $cryptoUtils = new Utils\Crypto();
231
        foreach ($ldap_certs as $ldap_cert) {
232
            $pem = $cryptoUtils->der2pem($ldap_cert);
233
            $ldap_cert_data = openssl_x509_parse($pem);
234
            if ($ldap_cert_data === false) {
235
                Logger::error('authX509: cert in LDAP is invalid for dn=' . $dn);
236
                continue;
237
            }
238
239
            if ($ldap_cert_data === $client_cert_data) {
240
                if (is_null($this->ldapConfig->getOptionalArray('attributes', null))) {
241
                    $attributes = $entry->getAttributes();
242
                } else {
243
                    $attributes = array_intersect_key(
244
                        $entry->getAttributes(),
245
                        array_fill_keys(array_values($this->ldapConfig->getArray('attributes')), null),
246
                    );
247
                }
248
                $state['Attributes'] = $attributes;
249
                $this->authSuccesful($state);
250
251
                throw new Exception("Should never be reached");
252
            }
253
        }
254
255
        Logger::error('authX509: no matching cert in LDAP for dn=' . $dn);
256
        $state['authX509.error'] = "UNKNOWNCERT";
257
        $this->authFailed($state);
258
259
        throw new Exception("Should never be reached");
260
    }
261
262
263
    /**
264
     * Finish a successful authentication.
265
     *
266
     * This function can be overloaded by a child authentication class that wish to perform some operations after login.
267
     *
268
     * @param array &$state Information about the current authentication.
269
     */
270
    public function authSuccesful(array &$state): void
271
    {
272
        Auth\Source::completeAuth($state);
273
274
        throw new Exception("Should never be reached");
275
    }
276
277
278
    /**
279
     * Find user in LDAP-store
280
     *
281
     * @param string $attr
282
     * @param string $value
283
     * @return \Symfony\Component\Ldap\Entry|null
284
     */
285
    public function findUserByAttribute(string $attr, string $value): ?Entry
286
    {
287
        $searchBase = $this->ldapConfig->getArray('search.base');
288
289
        $searchUsername = $this->ldapConfig->getOptionalString('search.username', null);
290
        Assert::nullOrnotWhitespaceOnly($searchUsername);
291
292
        $searchPassword = $this->ldapConfig->getOptionalString('search.password', null);
293
        Assert::nullOrnotWhitespaceOnly($searchPassword);
294
295
        $ldap = ConnectorFactory::fromAuthSource($this->backend);
296
        $connection = new Ldap($ldap->getAdapter());
297
298
        foreach ($searchBase as $base) {
299
            $ldapUserProvider = new LdapUserProvider($connection, $base, $searchUsername, $searchPassword, [], $attr);
300
            try {
301
                return $ldapUserProvider->loadUserByIdentifier($value)->getEntry();
302
            } catch (UserNotFoundException $e) {
303
                continue;
304
            }
305
        }
306
307
        // We haven't found the user
308
        return null;
309
    }
310
}
311