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">↑ 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