Mercurial > p > roundup > code
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'] |
