1<?php
2/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
3
4namespace Icinga\Forms\Config\UserBackend;
5
6use Exception;
7use Icinga\Data\ResourceFactory;
8use Icinga\Protocol\Ldap\LdapCapabilities;
9use Icinga\Protocol\Ldap\LdapConnection;
10use Icinga\Protocol\Ldap\LdapException;
11use Icinga\Web\Form;
12
13/**
14 * Form class for adding/modifying LDAP user backends
15 */
16class LdapBackendForm extends Form
17{
18    /**
19     * The ldap resource names the user can choose from
20     *
21     * @var array
22     */
23    protected $resources;
24
25    /**
26     * Default values for the form elements
27     *
28     * @var string[]
29     */
30    protected $suggestions = array();
31
32    /**
33     * Cache for {@link getLdapCapabilities()}
34     *
35     * @var LdapCapabilities
36     */
37    protected $ldapCapabilities;
38
39    /**
40     * Initialize this form
41     */
42    public function init()
43    {
44        $this->setName('form_config_authbackend_ldap');
45    }
46
47    /**
48     * Set the resource names the user can choose from
49     *
50     * @param   array   $resources      The resources to choose from
51     *
52     * @return  $this
53     */
54    public function setResources(array $resources)
55    {
56        $this->resources = $resources;
57        return $this;
58    }
59
60    /**
61     * Create and add elements to this form
62     *
63     * @param   array   $formData
64     */
65    public function createElements(array $formData)
66    {
67        $isAd = isset($formData['type']) ? $formData['type'] === 'msldap' : false;
68
69        $this->addElement(
70            'text',
71            'name',
72            array(
73                'required'      => true,
74                'label'         => $this->translate('Backend Name'),
75                'description'   => $this->translate(
76                    'The name of this authentication provider that is used to differentiate it from others.'
77                ),
78                'value'         => $this->getSuggestion('name')
79            )
80        );
81        $this->addElement(
82            'select',
83            'resource',
84            array(
85                'required'      => true,
86                'label'         => $this->translate('LDAP Connection'),
87                'description'   => $this->translate(
88                    'The LDAP connection to use for authenticating with this provider.'
89                ),
90                'multiOptions'  => !empty($this->resources)
91                    ? array_combine($this->resources, $this->resources)
92                    : array(),
93                'value'         => $this->getSuggestion('resource')
94            )
95        );
96
97        if (! $isAd && !empty($this->resources)) {
98            $this->addElement(
99                'button',
100                'discovery_btn',
101                array(
102                    'class'             => 'control-button',
103                    'type'              => 'submit',
104                    'value'             => 'discovery_btn',
105                    'label'             => $this->translate('Discover', 'A button to discover LDAP capabilities'),
106                    'title'             => $this->translate(
107                        'Push to fill in the chosen connection\'s default settings.'
108                    ),
109                    'decorators'        => array(
110                        array('ViewHelper', array('separator' => '')),
111                        array('Spinner'),
112                        array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls'))
113                    ),
114                    'formnovalidate'    => 'formnovalidate'
115                )
116            );
117        }
118
119        if ($isAd) {
120            // ActiveDirectory defaults
121            $userClass = 'user';
122            $filter = '!(objectClass=computer)';
123            $userNameAttribute = 'sAMAccountName';
124        } else {
125            // OpenLDAP defaults
126            $userClass = 'inetOrgPerson';
127            $filter = null;
128            $userNameAttribute = 'uid';
129        }
130
131        $this->addElement(
132            'text',
133            'user_class',
134            array(
135                'preserveDefault'   => true,
136                'required'          => ! $isAd,
137                'ignore'            => $isAd,
138                'disabled'          => $isAd ?: null,
139                'label'             => $this->translate('LDAP User Object Class'),
140                'description'       => $this->translate('The object class used for storing users on the LDAP server.'),
141                'value'             => $this->getSuggestion('user_class', $userClass)
142            )
143        );
144        $this->addElement(
145            'text',
146            'filter',
147            array(
148                'preserveDefault'   => true,
149                'allowEmpty'        => true,
150                'value'             => $this->getSuggestion('filter', $filter),
151                'label'             => $this->translate('LDAP Filter'),
152                'description'       => $this->translate(
153                    'An additional filter to use when looking up users using the specified connection. '
154                    . 'Leave empty to not to use any additional filter rules.'
155                ),
156                'requirement'       => $this->translate(
157                    'The filter needs to be expressed as standard LDAP expression.'
158                    . ' (e.g. &(foo=bar)(bar=foo) or foo=bar)'
159                ),
160                'validators'        => array(
161                    array(
162                        'Callback',
163                        false,
164                        array(
165                            'callback'  => function ($v) {
166                                // This is not meant to be a full syntax check. It will just
167                                // ensure that we can safely strip unnecessary parentheses.
168                                $v = trim($v);
169                                return ! $v || $v[0] !== '(' || (
170                                    strpos($v, ')(') !== false ? substr($v, -2) === '))' : substr($v, -1) === ')'
171                                );
172                            },
173                            'messages'  => array(
174                                'callbackValue' => $this->translate('The filter is invalid. Please check your syntax.')
175                            )
176                        )
177                    )
178                )
179            )
180        );
181        $this->addElement(
182            'text',
183            'user_name_attribute',
184            array(
185                'preserveDefault'   => true,
186                'required'          => ! $isAd,
187                'ignore'            => $isAd,
188                'disabled'          => $isAd ?: null,
189                'label'             => $this->translate('LDAP User Name Attribute'),
190                'description'       => $this->translate(
191                    'The attribute name used for storing the user name on the LDAP server.'
192                ),
193                'value'             => $this->getSuggestion('user_name_attribute', $userNameAttribute)
194            )
195        );
196        $this->addElement(
197            'hidden',
198            'backend',
199            array(
200                'disabled'  => true,
201                'value'     => $this->getSuggestion('backend', $isAd ? 'msldap' : 'ldap')
202            )
203        );
204        $this->addElement(
205            'text',
206            'base_dn',
207            array(
208                'preserveDefault'   => true,
209                'required'          => false,
210                'label'             => $this->translate('LDAP Base DN'),
211                'description'       => $this->translate(
212                    'The path where users can be found on the LDAP server. Leave ' .
213                    'empty to select all users available using the specified connection.'
214                ),
215                'value'             => $this->getSuggestion('base_dn')
216            )
217        );
218
219        $this->addElement(
220            'text',
221            'domain',
222            array(
223                'label'         => $this->translate('Domain'),
224                'description'   => $this->translate(
225                    'The domain the LDAP server is responsible for upon authentication.'
226                    . ' Note that if you specify a domain here,'
227                    . ' the LDAP backend only authenticates users who specify a domain upon login.'
228                    . ' If the domain of the user matches the domain configured here, this backend is responsible for'
229                    . ' authenticating the user based on the username without the domain part.'
230                    . ' If your LDAP backend holds usernames with a domain part or if it is not necessary in your setup'
231                    . ' to authenticate users based on their domains, leave this field empty.'
232                ),
233                'preserveDefault' => true,
234                'value'         => $this->getSuggestion('domain')
235            )
236        );
237
238        $this->addElement(
239            'button',
240            'btn_discover_domain',
241            array(
242                'class'             => 'control-button',
243                'type'              => 'submit',
244                'value'             => 'discovery_btn',
245                'label'             => $this->translate('Discover the domain'),
246                'title'             => $this->translate(
247                    'Push to disover and fill in the domain of the LDAP server.'
248                ),
249                'decorators'        => array(
250                    array('ViewHelper', array('separator' => '')),
251                    array('Spinner'),
252                    array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls'))
253                ),
254                'formnovalidate'    => 'formnovalidate'
255            )
256        );
257    }
258
259    public function isValidPartial(array $formData)
260    {
261        $isAd = isset($formData['type']) && $formData['type'] === 'msldap';
262        $baseDn = null;
263        $hasAdOid = false;
264        $discoverySuccessful = false;
265
266        if (! $isAd && ! empty($this->resources) && isset($formData['discovery_btn'])
267            && $formData['discovery_btn'] === 'discovery_btn') {
268            $discoverySuccessful = true;
269            try {
270                $capabilities = $this->getLdapCapabilities($formData);
271                $baseDn = $capabilities->getDefaultNamingContext();
272                $hasAdOid = $capabilities->isActiveDirectory();
273            } catch (Exception $e) {
274                $this->warning(sprintf(
275                    $this->translate('Failed to discover the chosen LDAP connection: %s'),
276                    $e->getMessage()
277                ));
278                $discoverySuccessful = false;
279            }
280        }
281
282        if ($discoverySuccessful) {
283            if ($isAd || $hasAdOid) {
284                // ActiveDirectory defaults
285                $userClass = 'user';
286                $filter = '!(objectClass=computer)';
287                $userNameAttribute = 'sAMAccountName';
288            } else {
289                // OpenLDAP defaults
290                $userClass = 'inetOrgPerson';
291                $filter = null;
292                $userNameAttribute = 'uid';
293            }
294
295            $formData['user_class'] = $userClass;
296
297            if (! isset($formData['filter']) || $formData['filter'] === '') {
298                $formData['filter'] = $filter;
299            }
300
301            $formData['user_name_attribute'] = $userNameAttribute;
302
303            if ($baseDn !== null && (! isset($formData['base_dn']) || $formData['base_dn'] === '')) {
304                $formData['base_dn'] = $baseDn;
305            }
306        }
307
308        if (isset($formData['btn_discover_domain']) && $formData['btn_discover_domain'] === 'discovery_btn') {
309            try {
310                $formData['domain'] = $this->discoverDomain($formData);
311            } catch (LdapException $e) {
312                $this->error($e->getMessage());
313            }
314        }
315
316        return parent::isValidPartial($formData);
317    }
318
319    /**
320     * Get the LDAP capabilities of either the resource specified by the user or the default one
321     *
322     * @param   string[]    $formData
323     *
324     * @return  LdapCapabilities
325     */
326    protected function getLdapCapabilities(array $formData)
327    {
328        if ($this->ldapCapabilities === null) {
329            $this->ldapCapabilities = ResourceFactory::create(
330                isset($formData['resource']) ? $formData['resource'] : reset($this->resources)
331            )->bind()->getCapabilities();
332        }
333
334        return $this->ldapCapabilities;
335    }
336
337    /**
338     * Discover the domain the LDAP server is responsible for
339     *
340     * @param   string[]    $formData
341     *
342     * @return  string
343     */
344    protected function discoverDomain(array $formData)
345    {
346        $cap = $this->getLdapCapabilities($formData);
347
348        if ($cap->isActiveDirectory()) {
349            $netBiosName = $cap->getNetBiosName();
350            if ($netBiosName !== null) {
351                return $netBiosName;
352            }
353        }
354
355        return $this->defaultNamingContextToFQDN($cap);
356    }
357
358    /**
359     * Get the default naming context as FQDN
360     *
361     * @param   LdapCapabilities    $cap
362     *
363     * @return  string|null
364     */
365    protected function defaultNamingContextToFQDN(LdapCapabilities $cap)
366    {
367        $defaultNamingContext = $cap->getDefaultNamingContext();
368        if ($defaultNamingContext !== null) {
369            $validationMatches = array();
370            if (preg_match('/\bdc=[^,]+(?:,dc=[^,]+)*$/', strtolower($defaultNamingContext), $validationMatches)) {
371                $splitMatches = array();
372                preg_match_all('/dc=([^,]+)/', $validationMatches[0], $splitMatches);
373                return implode('.', $splitMatches[1]);
374            }
375        }
376    }
377
378    /**
379     * Get the default values for the form elements
380     *
381     * @return string[]
382     */
383    public function getSuggestions()
384    {
385        return $this->suggestions;
386    }
387
388    /**
389     * Get the default value for the given form element or the given default
390     *
391     * @param   string  $element
392     * @param   string  $default
393     *
394     * @return  string
395     */
396    public function getSuggestion($element, $default = null)
397    {
398        return isset($this->suggestions[$element]) ? $this->suggestions[$element] : $default;
399    }
400
401    /**
402     * Set the default values for the form elements
403     *
404     * @param string[] $suggestions
405     *
406     * @return $this
407     */
408    public function setSuggestions(array $suggestions)
409    {
410        $this->suggestions = $suggestions;
411
412        return $this;
413    }
414}
415