comparison test/test_liveserver.py @ 8412:0663a7bcef6c reauth-confirm_id

feat: finish reauth docs, enhance code. Decided to keep name Reauth for now. admin_guide.txt: add reference mark to roundup admin help. Used for template command reference in upgrading.txt. customizing.txt: added worked example of adding a reauth auditor for address and password. Also links to OWASP recommendations. Added link to example code in design.doc on detectors. glossary.txt: reference using roundup-admin template command in def for tracker templates. pydoc.txt: Added methods for Client class. Added class and methods for (cgi) Action, LoginAction and ReauthAction. reference.txt Edited and restructured detector section. Added section on registering a detector and priority use/execution order. (reference to design doc was used before). Added/enhanced description of exception an auditor can raise (includes Reauth). Added section on Reauth implementation and use (Confirming the User). Also has paragraph on future ideas. upgrading.txt Stripped down the original section. Moved a lot to reference.txt. Referenced customizing example, mention installation of _generic.reauth.html and reference reference.txt. cgi/actions.py: fixed bad ReST that was breaking pydoc.txt processing changed doc on limitations of Reauth code. added docstring for Reauth::verifyPassword cgi/client.py: fix ReST for a method breaking pydoc.py processing cgi/templating.py: fix docstring on embed_form_fields templates/*/html/_generic.reauth.html disable spelling for password field add timing info to the javascript function that processes file data. reformat javascript IIFE templates/jinja2/html/_generic.reauth.html create a valid jinja2 template. Looks like my original jinja template got overwritten and committed. feature parity with the other reauth templates. test/test_liveserver.py add test case for Reauth workflow. Makefile add doc.
author John Rouillard <rouilj@ieee.org>
date Wed, 13 Aug 2025 23:52:49 -0400
parents 51f277ed8adc
children cc3edb260c1b
comparison
equal deleted inserted replaced
8411:ef1ea918b07a 8412:0663a7bcef6c
7 from roundup import password 7 from roundup import password
8 from roundup.anypy.strings import b2s 8 from roundup.anypy.strings import b2s
9 from roundup.cgi.wsgi_handler import RequestDispatcher 9 from roundup.cgi.wsgi_handler import RequestDispatcher
10 from .wsgi_liveserver import LiveServerTestCase 10 from .wsgi_liveserver import LiveServerTestCase
11 from . import db_test_base 11 from . import db_test_base
12 from textwrap import dedent
12 from time import sleep 13 from time import sleep
13 from .test_postgresql import skip_postgresql 14 from .test_postgresql import skip_postgresql
14 15
15 from wsgiref.validate import validator 16 from wsgiref.validate import validator
16 17
107 ''' 108 '''
108 # tests in this class. 109 # tests in this class.
109 # set up and open a tracker 110 # set up and open a tracker
110 cls.instance = db_test_base.setupTracker(cls.dirname, cls.backend) 111 cls.instance = db_test_base.setupTracker(cls.dirname, cls.backend)
111 112
113 # add an auditor that triggers a Reauth
114 with open("%s/detectors/reauth.py" % cls.dirname, "w") as f:
115 auditor = dedent("""
116 from roundup.cgi.exceptions import Reauth
117
118 def trigger_reauth(db, cl, nodeid, newvalues):
119 if 'realname' in newvalues and not hasattr(db, 'reauth_done'):
120 raise Reauth('Add an optional message to the user')
121
122 def init(db):
123 db.user.audit('set', trigger_reauth, priority=110)
124 """)
125 f.write(auditor)
126
112 # open the database 127 # open the database
113 cls.db = cls.instance.open('admin') 128 cls.db = cls.instance.open('admin')
114 129
115 # add a user without edit access for status. 130 # add a user without edit access for status.
116 cls.db.user.create(username="fred", roles='User', 131 cls.db.user.create(username="fred", roles='User',
117 password=password.Password('sekrit'), address='fred@example.com') 132 password=password.Password('sekrit'), address='fred@example.com')
133
134 # add a user for reauth tests
135 cls.db.user.create(username="reauth",
136 realname="reauth test user",
137 password=password.Password("reauth"),
138 address="reauth@example.com", roles="User")
118 139
119 # set the url the test instance will run at. 140 # set the url the test instance will run at.
120 cls.db.config['TRACKER_WEB'] = cls.tracker_web 141 cls.db.config['TRACKER_WEB'] = cls.tracker_web
121 # set up mailhost so errors get reported to debuging capture file 142 # set up mailhost so errors get reported to debuging capture file
122 cls.db.config.MAILHOST = "localhost" 143 cls.db.config.MAILHOST = "localhost"
123 cls.db.config.MAIL_HOST = "localhost" 144 cls.db.config.MAIL_HOST = "localhost"
124 cls.db.config.MAIL_DEBUG = "../_test_tracker_mail.log" 145 cls.db.config.MAIL_DEBUG = "../_test_tracker_mail.log"
146
147 # also report it in the web.
148 cls.db.config.WEB_DEBUG = "yes"
125 149
126 # added to enable csrf forgeries/CORS to be tested 150 # added to enable csrf forgeries/CORS to be tested
127 cls.db.config.WEB_CSRF_ENFORCE_HEADER_ORIGIN = "required" 151 cls.db.config.WEB_CSRF_ENFORCE_HEADER_ORIGIN = "required"
128 cls.db.config.WEB_ALLOWED_API_ORIGINS = "https://client.com" 152 cls.db.config.WEB_ALLOWED_API_ORIGINS = "https://client.com"
129 cls.db.config['WEB_CSRF_ENFORCE_HEADER_X-REQUESTED-WITH'] = "required" 153 cls.db.config['WEB_CSRF_ENFORCE_HEADER_X-REQUESTED-WITH'] = "required"
334 class BaseTestCases(WsgiSetup, ClientSetup): 358 class BaseTestCases(WsgiSetup, ClientSetup):
335 """Class with all tests to run against wsgi server. Is reused when 359 """Class with all tests to run against wsgi server. Is reused when
336 wsgi server is started with various feature flags 360 wsgi server is started with various feature flags
337 """ 361 """
338 362
363 def test_reauth_workflow(self):
364 """as admin user:
365 change reauth user realname include all fields on the form
366 also add a dummy file to the submitted request.
367 get back a reauth page/template (look for id="reauth_form")
368 verify hidden input for realname
369 verify hidden input for roles
370 verify the base 64 file content are on the page.
371
372 submit form with bad password
373 verify error reported
374 verify hidden input for realname
375 (note the file contents will be gone because
376 preserving that requires javascript)
377
378 enter good password
379 verify on user page (look for
380 "(the default is 0)" hint for timezone)
381 verify new name present
382 verify success banner
383 """
384 from html.parser import HTMLParser
385 class HTMLExtractForm(HTMLParser):
386 """Custom parser to extract input fields from a form.
387
388 Set the form_label to extract inputs only inside a form
389 with a name or id matching form_label. Default is
390 "reauth_form".
391
392 Set names to a tuple/list/set with the names of the
393 inputs you are interested in. Defalt is None which
394 extracts all inputs on the page with a name property.
395 """
396 def __init__(self, names=None, form_label="reauth_form"):
397 super().__init__()
398 self.fields = {}
399 self.names = names
400 self.form_label = form_label
401 self._inside_form = False
402
403 def handle_starttag(self, tag, attrs):
404 if tag == 'form':
405 for attr, value in attrs:
406 if attr in ('id', 'name') and value == self.form_label:
407 self._inside_form = True
408 return
409
410 if not self._inside_form: return
411
412 if tag == 'input':
413 field_name = None
414 field_value = None
415 for attr, value in attrs:
416 if attr == 'name':
417 field_name = value
418 if attr == 'value':
419 field_value = value
420
421 # skip input type="submit" without name
422 if not field_name: return
423
424 if self.names is None:
425 self.fields[field_name] = field_value
426 elif field_name in self.names:
427 self.fields[field_name] = field_value
428
429 def handle_endtag(self, tag):
430 if tag == "form":
431 self._inside_form = False
432
433 def get_fields(self):
434 return self.fields
435
436
437 user_url = "%s/user%s" % (self.url_base(),
438 self.db.user.lookup('reauth'))
439
440 session, _response = self.create_login_session()
441
442 user_page = session.get(user_url)
443
444 self.assertEqual(user_page.status_code, 200)
445 self.assertTrue(b'reauth' in user_page.content)
446
447 parser = HTMLExtractForm(('@lastactivity', '@csrf'), 'itemSynopsis')
448 parser.feed(user_page.text)
449
450 change = {"realname": "reauth1",
451 "username": "reauth",
452 "password": "",
453 "@confirm@password": "",
454 "phone": "",
455 "organisation": "",
456 "roles": "User",
457 "timezone": "",
458 "address": "reauth@example.com",
459 "alternate_addresses": "",
460 "@template": "item",
461 "@required": "username,address",
462 "@submit_button": "Submit Changes",
463 "@action": "edit",
464 **parser.get_fields()
465 }
466 lastactivity = parser.get_fields()['@lastactivity']
467
468 # make the simple name/value dict into a name/tuple dict
469 # setting tuple[0] to None to indicate pulre string
470 # value. Then we use change2 with file to trigger
471 # multipart/form-data form encoding which preserves fields
472 # with empty values. application/x-www-form-urlencoded forms
473 # have fields with empty values dropped by cgi by default.
474 userpage_change = {key: (None, value) for key, value in change.items()}
475 userpage_change.update({"@file": ("filename.txt", "this is some text")})
476
477 on_reauth = session.post(user_url, files=userpage_change)
478
479 self.assertIn(b'id="reauth_form"', on_reauth.content)
480 self.assertIn(b'Please enter your password to continue with',
481 on_reauth.content)
482 # make sure the base64 encoded content for @file is present on
483 # the page. Because we are not running a javascript capable
484 # browser, it is not converted into an actual file input.
485 # But this check shows that a file generated by reauth is trying
486 # to maintain the file input.
487 self.assertIn(b'dGhpcyBpcyBzb21lIHRleHQ=', on_reauth.content)
488
489 parser = HTMLExtractForm()
490 parser.feed(on_reauth.text)
491 fields = parser.get_fields()
492 self.assertEqual(fields["@lastactivity"], lastactivity)
493 self.assertEqual(fields["@next_action"], "edit")
494 self.assertEqual(fields["@action"], "reauth")
495 self.assertEqual(fields["address"], "reauth@example.com")
496 self.assertEqual(fields["phone"], "")
497 self.assertEqual(fields["roles"], "User")
498 self.assertEqual(fields["realname"], "reauth1")
499
500 reauth_fields = {
501 "@reauth_password": (None, "sekret not right"),
502 "submit": (None, " Authorize Change "),
503 }
504 reauth_submit = {key: (None, value) for key, value in fields.items()}
505 reauth_submit.update(reauth_fields)
506
507 fail_reauth = session.post(user_url,
508 files=reauth_submit)
509 self.assertIn(b'id="reauth_form"', fail_reauth.content)
510 self.assertIn(b'Please enter your password to continue with',
511 fail_reauth.content)
512 self.assertIn(b'Password incorrect', fail_reauth.content)
513
514 parser = HTMLExtractForm(('@csrf',))
515 parser.feed(fail_reauth.text)
516 # remeber we are logged in as admin - use admin pw.
517 reauth_submit.update({"@reauth_password": (None, "sekrit"),
518 "@csrf":
519 (None, parser.get_fields()['@csrf'])})
520 pass_reauth = session.post(user_url,
521 files=reauth_submit)
522 self.assertNotIn(b'id="reauth_form"', pass_reauth.content)
523 self.assertNotIn(b'Please enter your password to continue with',
524 pass_reauth.content)
525 self.assertIn(b'user 4 realname edited ok', pass_reauth.content)
526 self.assertIn(b'(the default is 0)', pass_reauth.content)
527
339 def test_cookie_attributes(self): 528 def test_cookie_attributes(self):
340 session, _response = self.create_login_session() 529 session, _response = self.create_login_session()
341 530
342 cookie_box = session.cookies._cookies['localhost.local']['/'] 531 cookie_box = session.cookies._cookies['localhost.local']['/']
343 cookie = cookie_box['roundup_session_Roundupissuetracker'] 532 cookie = cookie_box['roundup_session_Roundupissuetracker']

Roundup Issue Tracker: http://roundup-tracker.org/