1# -*- coding: ascii -*-
2"""
3web2ldap.app.gui: basic functions for GUI elements
4
5web2ldap - a web-based LDAP Client,
6see https://www.web2ldap.de for details
7
8(c) 1998-2021 by Michael Stroeder <michael@stroeder.com>
9
10This software is distributed under the terms of the
11Apache License Version 2.0 (Apache-2.0)
12https://www.apache.org/licenses/LICENSE-2.0
13"""
14
15import re
16import time
17from hashlib import md5
18
19import ldap0
20from ldap0.ldapurl import LDAPUrl
21import ldap0.filter
22from ldap0.dn import DNObj
23from ldap0.res import SearchResultEntry
24
25try:
26    import dns
27except ImportError:
28    DNS_AVAIL = False
29else:
30    DNS_AVAIL = True
31
32import web2ldapcnf
33
34from ..web.forms import Select as SelectField
35from ..__about__ import __version__
36from ..web import escape_html
37from ..ldaputil import logdb_filter
38from .tmpl import read_template
39
40
41# Sequence of patterns to extract attribute names from LDAP diagnostic messages
42EXTRACT_INVALID_ATTR_PATTERNS = (
43    # OpenLDAP diagnostic messages
44    re.compile(r"(?P<at>[a-zA-Z0-9;-]+): value #(?P<index>[0-9]+) invalid per syntax"),
45    re.compile(r"object class '(?P<oc>[a-zA-Z0-9;-]+)' requires attribute '(?P<at>[a-zA-Z0-9;-]+)'"),
46)
47
48
49#---------------------------------------------------------------------------
50# Constants
51#---------------------------------------------------------------------------
52
53HIDDEN_FIELD = '<input type="hidden" name="%s" value="%s">%s\n'
54
55HTML_OFF_S = {False:'', True:'<!--'}
56HTML_OFF_E = {False:'', True:'-->'}
57
58HTML_FOOTER = """
59  <p class="ScrollLink">
60    <a href="#web2ldap_top">&uarr; TOP</a>
61  </p>
62  <a id="web2ldap_bottom"></a>
63</div>
64<div id="Footer">
65  <footer>
66  </footer>
67</div>
68</body>
69</html>
70"""
71
72
73def dn_anchor_hash(dn):
74    return str(md5(dn.encode('utf-8')).hexdigest())
75
76
77def command_div(
78        commandlist,
79        div_id='CommandDiv',
80        separator=' ',
81        semantic_tag='nav',
82    ):
83    if semantic_tag:
84        start_tag = '<%s>' % semantic_tag
85        end_tag = '<%s>' % semantic_tag
86    else:
87        start_tag = ''
88        end_tag = ''
89    if commandlist:
90        return '%s<p id="%s" class="CT">\n%s\n</p>%s\n' % (
91            start_tag,
92            div_id,
93            (separator).join(commandlist),
94            end_tag,
95        )
96    return ''
97    # end of command_div()
98
99
100def simple_main_menu(app):
101    mil = [app.anchor('', 'Connect', [])]
102    if app.check_access('monitor'):
103        mil.append(app.anchor('monitor', 'Monitor', []))
104    if DNS_AVAIL and app.check_access('locate'):
105        mil.append(app.anchor('locate', 'DNS lookup', []))
106    return mil
107
108
109def context_menu_single_entry(app, vcard_link=0, dds_link=0, entry_uuid=None):
110    """
111    Output the context menu for a single entry
112    """
113    dn_disp = app.dn or 'Root DSE'
114    mil = [
115        app.anchor(
116            'read', 'Raw',
117            [
118                ('dn', app.dn),
119                ('read_output', 'table'),
120                ('read_expandattr', '*')
121            ],
122            title='Display entry\r\n%s\r\nas raw attribute type/value list' % (dn_disp)
123        ),
124    ]
125    if app.dn:
126        ldap_url_obj = app.ls.ldap_url('', add_login=False)
127        mil.extend([
128            app.anchor(
129                'login', 'Bind as',
130                [
131                    ('ldapurl', str(ldap_url_obj)),
132                    ('dn', app.dn),
133                    ('login_who', app.dn),
134                ],
135                title='Connect and bind new session as\r\n%s' % (app.dn)
136            ),
137            app.anchor(
138                'modify', 'Modify',
139                [('dn', app.dn)],
140                title='Modify entry\r\n%s' % (app.dn)
141            ),
142            app.anchor(
143                'rename', 'Rename',
144                [('dn', app.dn)],
145                title='Rename/move entry\r\n%s' % (app.dn)
146            ),
147            app.anchor(
148                'delete', 'Delete',
149                [('dn', app.dn)],
150                title='Delete entry and/or subtree\r\n%s' % (app.dn)
151            ),
152            app.anchor(
153                'passwd', 'Password',
154                [('dn', app.dn), ('passwd_who', app.dn)],
155                title='Set password for entry\r\n%s' % (app.dn)
156            ),
157            app.anchor(
158                'groupadm', 'Groups',
159                [('dn', app.dn)],
160                title='Change group membership of entry\r\n%s' % (app.dn)
161            ),
162            app.anchor(
163                'add', 'Clone',
164                [
165                    ('dn', app.parent_dn),
166                    ('add_clonedn', app.dn),
167                    ('in_ft', 'Template'),
168                ],
169                title='Clone entry\r\n%s\r\nbeneath %s' % (app.dn, app.parent_dn)
170            ),
171        ])
172
173    if vcard_link:
174        mil.append(
175            app.anchor(
176                'read', 'vCard',
177                [('dn', app.dn), ('read_output', 'vcard')],
178                title='Export entry\r\n%s\r\nas vCard' % (dn_disp)
179            )
180        )
181
182    if dds_link:
183        mil.append(
184            app.anchor(
185                'dds', 'Refresh',
186                [('dn', app.dn)],
187                title='Refresh dynamic entry %s' % (dn_disp)
188            )
189        )
190
191    if app.audit_context:
192        accesslog_any_filterstr = logdb_filter('auditObject', app.dn, entry_uuid)
193        accesslog_write_filterstr = logdb_filter('auditWriteObject', app.dn, entry_uuid)
194        mil.extend([
195            app.anchor(
196                'search', 'Audit access',
197                [
198                    ('dn', app.audit_context),
199                    ('filterstr', accesslog_any_filterstr),
200                    ('scope', str(ldap0.SCOPE_ONELEVEL)),
201                ],
202                title='Complete audit trail for entry\r\n%s' % (app.dn),
203            ),
204            app.anchor(
205                'search', 'Audit writes',
206                [
207                    ('dn', app.audit_context),
208                    ('filterstr', accesslog_write_filterstr),
209                    ('scope', str(ldap0.SCOPE_ONELEVEL)),
210                ],
211                title='Audit trail of write access to entry\r\n%s' % (app.dn),
212            ),
213        ])
214
215    try:
216        changelog_dn = app.ls.root_dse['changelog'][0].decode(app.ls.charset)
217    except KeyError:
218        pass
219    else:
220        changelog_filterstr = logdb_filter('changeLogEntry', app.dn, entry_uuid)
221        mil.append(
222            app.anchor(
223                'search', 'Change log',
224                [
225                    ('dn', changelog_dn),
226                    ('filterstr', changelog_filterstr),
227                    ('scope', str(ldap0.SCOPE_ONELEVEL)),
228                ],
229                title='Audit trail of write access to current entry',
230            )
231        )
232
233    try:
234        monitor_context_dn = app.ls.root_dse['monitorContext'][0].decode(app.ls.charset)
235    except KeyError:
236        pass
237    else:
238        mil.append(app.anchor(
239            'search', 'User conns',
240            [
241                ('dn', monitor_context_dn),
242                (
243                    'filterstr',
244                    '(&(objectClass=monitorConnection)(monitorConnectionAuthzDN=%s))' % (
245                        ldap0.filter.escape_str(app.dn),
246                    ),
247                ),
248                ('scope', str(ldap0.SCOPE_SUBTREE)),
249            ],
250            title='Find connections of this user in monitor database',
251        ))
252
253    return mil
254    # end of context_menu_single_entry()
255
256
257def main_menu(app):
258    """
259    Returns list of main menu items
260    """
261    mil = []
262
263    if app.ls is not None and app.ls.uri is not None:
264
265        if app.dn and app.dn_obj != app.naming_context:
266            mil.append(
267                app.anchor(
268                    'search', 'Up',
269                    (
270                        ('dn', app.parent_dn),
271                        ('scope', str(ldap0.SCOPE_ONELEVEL)),
272                        ('searchform_mode', 'adv'),
273                        ('search_attr', 'objectClass'),
274                        ('search_option', '({at}=*)'),
275                        ('search_string', ''),
276                    ),
277                    title='List direct subordinates of %s' % (app.parent_dn or 'Root DSE'),
278                )
279            )
280
281        mil.extend((
282            app.anchor(
283                'search', 'Down',
284                (
285                    ('dn', app.dn),
286                    ('scope', str(ldap0.SCOPE_ONELEVEL)),
287                    ('searchform_mode', 'adv'),
288                    ('search_attr', 'objectClass'),
289                    ('search_option', '({at}=*)'),
290                    ('search_string', ''),
291                ),
292                title='List direct subordinates of %s' % (app.dn or 'Root DSE'),
293            ),
294            app.anchor(
295                'searchform', 'Search',
296                (('dn', app.dn),),
297                title='Enter search criteria in input form',
298            ),
299        ))
300
301        mil.append(
302            app.anchor(
303                'dit', 'Tree',
304                [('dn', app.dn)],
305                title='Display tree around %s' % (app.dn or 'Root DSE'),
306                anchor_id=dn_anchor_hash(app.dn_obj)
307            ),
308        )
309
310        mil.append(
311            app.anchor(
312                'read', 'Read',
313                [('dn', app.dn), ('read_nocache', '1')],
314                title='Display entry %s' % (app.dn or 'Root DSE'),
315            ),
316        )
317
318        mil.extend((
319            app.anchor(
320                'add', 'New entry',
321                [('dn', app.dn)],
322                title='Add a new entry below of %s' % (app.dn or 'Root DSE')
323            ),
324            app.anchor(
325                'conninfo', 'ConnInfo',
326                [('dn', app.dn)],
327                title='Show information about HTTP and LDAP connections'
328            ),
329            app.anchor(
330                'params', 'Params',
331                [('dn', app.dn)],
332                title='Tweak parameters used for LDAP operations (controls etc.)'
333            ),
334            app.anchor('login', 'Bind', [('dn', app.dn)], title='Login to directory'),
335            app.anchor('oid', 'Schema', [('dn', app.dn)], title='Browse/view subschema'),
336        ))
337
338        mil.append(app.anchor('disconnect', 'Disconnect', (), title='Disconnect from LDAP server'))
339
340    else:
341
342        mil.append(app.anchor('', 'Connect', (), title='New connection to LDAP server'))
343
344    return mil
345    # end of main_menu()
346
347
348def dit_navigation(app):
349    dnil = [
350        app.anchor(
351            'read',
352            app.form.s2d(str(app.dn_obj.slice(i, i+1)) or '[Root DSE]'),
353            [('dn', str(app.dn_obj.slice(i, None)))],
354            title='Jump to %s' % (str(app.dn_obj.slice(i, None))),
355        )
356        for i in range(len(app.dn_obj))
357    ]
358    dnil.append(
359        app.anchor(
360            'read', '[Root DSE]',
361            [('dn', '')],
362            title='Jump to root DSE',
363        )
364    )
365    return dnil
366    # end of dit_navigation()
367
368
369def top_section(
370        app,
371        title,
372        main_menu_list,
373        context_menu_list=None,
374        main_div_id='Message',
375    ):
376
377    # First send the HTTP header
378    header(app, 'text/html', app.form.accept_charset)
379
380    # Read the template file for TopSection
381    top_template_str = read_template(app, 'top_template', 'top section')
382
383    script_name = escape_html(app.form.script_name)
384
385    template_dict = {
386        'main_div_id': main_div_id,
387        'accept_charset': app.form.accept_charset,
388        'refresh_time': str(web2ldapcnf.session_remove),
389        'sid': app.sid or '',
390        'title_text': title,
391        'script_name': script_name,
392        'web2ldap_version': escape_html(__version__),
393        'command': app.command,
394        'ldap_url': '',
395        'ldap_uri': '-/-',
396        'description': '',
397        'who': '-/-',
398        'dn': '-/-',
399        'dit_navi': '-/-',
400        'main_menu': command_div(
401            main_menu_list,
402            div_id='MainMenu',
403            separator='\n',
404            semantic_tag=None,
405        ),
406        'context_menu': command_div(
407            context_menu_list,
408            div_id='ContextMenu',
409            separator='\n',
410            semantic_tag=None,
411        ),
412    }
413    template_dict.update([(k, escape_html(str(v))) for k, v in app.env.items()])
414
415    if app.ls is not None and app.ls.uri is not None:
416        # Only output something meaningful if valid connection
417        template_dict.update({
418            'ldap_url': app.ls.ldap_url(app.dn),
419            'ldap_uri': app.form.s2d(app.ls.uri),
420            'description': escape_html(app.cfg_param('description', '')),
421            'dit_navi': ',\n'.join(dit_navigation(app)),
422            'dn': app.form.s2d(app.dn),
423        })
424        template_dict['who'] = app.display_authz_dn()
425
426    app.outf.write(top_template_str.format(**template_dict))
427    # end of top_section()
428
429
430def attrtype_select_field(
431        app,
432        field_name,
433        field_desc,
434        attr_list,
435        default_attr_options=None
436    ):
437    """
438    Return web2ldap.web.forms.Select instance for choosing attribute type names
439    """
440    attr_options_dict = {}
441    for attr_type in (
442            default_attr_options
443            or list(app.schema.sed[ldap0.schema.models.AttributeType].keys())+attr_list
444        ):
445        attr_type_se = app.schema.get_obj(ldap0.schema.models.AttributeType, attr_type)
446        if attr_type_se:
447            if attr_type_se.names:
448                attr_type_name = attr_type_se.names[0]
449            else:
450                attr_type_name = attr_type
451            attr_type_desc = attr_type_se.desc
452        else:
453            attr_type_name = attr_type
454            attr_type_desc = None
455        attr_options_dict[attr_type_name] = (attr_type_name, attr_type_desc)
456    sorted_attr_options = [
457        (at, attr_options_dict[at][0], attr_options_dict[at][1])
458        for at in sorted(attr_options_dict.keys(), key=str.lower)
459    ]
460    # Create a select field instance for attribute type name
461    attr_select = SelectField(
462        field_name, field_desc, 1,
463        options=sorted_attr_options,
464        auto_add_option=True,
465    )
466    attr_select.charset = app.form.accept_charset
467    return attr_select
468    # end of attrtype_select_field()
469
470
471def gen_headers(content_type, charset, more_headers=None):
472    # Get current time as GMT (seconds since epoch)
473    current_datetime = time.strftime('%a, %d %b %Y %H:%M:%S GMT', time.gmtime(time.time()))
474    headers = []
475    if content_type.startswith('text/'):
476        content_type = '%s;charset=%s' % (content_type, charset)
477    headers.append(('Content-Type', content_type))
478    headers.append(('Date', current_datetime))
479    headers.append(('Last-Modified', current_datetime))
480    headers.append(('Expires', current_datetime))
481    for name, val in web2ldapcnf.http_headers.items():
482        headers.append((name, val))
483    headers.extend(more_headers or [])
484    return headers
485    # end of gen_headers()
486
487
488def header(app, content_type, charset, more_headers=None):
489    headers = gen_headers(
490        content_type=content_type,
491        charset=charset,
492        more_headers=more_headers,
493    )
494    app.outf.reset()
495    if app.form.next_cookie:
496        for _, cookie in app.form.next_cookie.items():
497            headers.append(('Set-Cookie', str(cookie)[12:]))
498    if app.form.env.get('HTTPS', 'off') == 'on' and \
499       'Strict-Transport-Security' not in web2ldapcnf.http_headers:
500        headers.append(('Strict-Transport-Security', 'max-age=15768000 ; includeSubDomains'))
501    app.outf.set_headers(headers)
502    return headers
503    # end of header()
504
505
506def footer(app):
507    app.outf.write(HTML_FOOTER)
508
509
510def search_root_field(
511        app,
512        name='dn',
513        text='Search Root',
514        default=None,
515        search_root_searchurl=None,
516    ):
517    """
518    Returns input field for search root
519    """
520
521    def sortkey_func(d):
522        if isinstance(d, DNObj):
523            return str(reversed(d)).lower()
524        try:
525            dn, _ = d
526        except ValueError:
527            dn = d
528        if not dn:
529            return ''
530        return str(reversed(DNObj.from_str(dn))).lower()
531
532    # add all known naming contexts
533    dn_select_list = set(map(str, app.ls.namingContexts))
534    if app.dn:
535        # add the current DN and all its parent DNs
536        dn_select_list.update(map(str, [app.dn_obj] + app.dn_obj.parents()))
537    if search_root_searchurl:
538        # search for more search bases
539        slu = LDAPUrl(search_root_searchurl)
540        try:
541            ldap_results = app.ls.l.search_s(
542                slu.dn,
543                slu.scope,
544                slu.filterstr,
545                attrlist=['1.1']
546            )
547        except ldap0.LDAPError:
548            pass
549        else:
550            dn_select_list.update([
551                r.dn_s
552                for r in ldap_results
553                if isinstance(r, SearchResultEntry)
554            ])
555    # Remove empty search base string because it will re-added with description
556    if '' in dn_select_list:
557        dn_select_list.remove('')
558    # Add root search base string with description
559    dn_select_list.add(('', '- World -'))
560    srf = SelectField(
561        name, text, 1,
562        size=1,
563        options=sorted(
564            dn_select_list,
565            key=sortkey_func,
566        ),
567        default=default or str(app.naming_context) or app.dn,
568        ignoreCase=1
569    )
570    srf.charset = app.form.accept_charset
571    return srf
572    # end of search_root_field()
573
574
575def invalid_syntax_message(app, invalid_attrs):
576    invalid_attr_types_ui = [
577        app.form.s2d(at)
578        for at in sorted(invalid_attrs.keys())
579    ]
580    return 'Wrong syntax in following attributes: %s' % (
581        ', '.join([
582            '<a class="CL" href="#in_a_%s">%s</a>' % (at_ui, at_ui)
583            for at_ui in invalid_attr_types_ui
584        ])
585    )
586
587
588def extract_invalid_attr(app, ldap_err):
589    """
590    Extract invalid attributes dict from diagnostic message in LDAPError instance
591    """
592    try:
593        # try to extract OpenLDAP-specific info message
594        info = ldap_err.args[0].get('info', b'').decode(app.ls.charset)
595    except (AttributeError, IndexError, UnicodeDecodeError):
596        # could not extract useful info
597        return (app.ldap_error_msg(ldap_err), {})
598    invalid_attrs = {}
599    for extract_pattern in EXTRACT_INVALID_ATTR_PATTERNS:
600        match = extract_pattern.match(info)
601        if match is None:
602            continue
603        matches = match.groupdict()
604        if 'at' in matches:
605            invalid_attrs[matches['at']] = [int(matches.get('index', 0))]
606            if isinstance(ldap_err, ldap0.INVALID_SYNTAX):
607                error_msg = invalid_syntax_message(app, invalid_attrs)
608            else:
609                error_msg = app.ldap_error_msg(ldap_err)
610            break
611    return error_msg, invalid_attrs
612
613
614def exception_message(app, h1_msg, error_msg):
615    """
616    h1_msg
617      Unicode string with text for the <h1> heading
618    error_msg
619      Raw string with HTML with text describing the exception
620      (Security note: Must already be quoted/escaped!)
621    """
622    top_section(app, 'Error', main_menu(app), context_menu_list=[])
623    app.outf.write(
624        """
625        <h1>{heading}</h1>
626        <p class="ErrorMessage">
627          {error_msg}
628        </p>
629        """.format(
630            heading=app.form.s2d(h1_msg),
631            error_msg=error_msg,
632        )
633    )
634    footer(app)
635    # end of exception_message()
636