diff 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
line wrap: on
line diff
--- a/test/test_liveserver.py	Mon Aug 11 14:01:12 2025 -0400
+++ b/test/test_liveserver.py	Wed Aug 13 23:52:49 2025 -0400
@@ -9,6 +9,7 @@
 from roundup.cgi.wsgi_handler import RequestDispatcher
 from .wsgi_liveserver import LiveServerTestCase
 from . import db_test_base
+from textwrap import dedent
 from time import sleep
 from .test_postgresql import skip_postgresql
 
@@ -109,6 +110,20 @@
         # set up and open a tracker
         cls.instance = db_test_base.setupTracker(cls.dirname, cls.backend)
 
+        # add an auditor that triggers a Reauth
+        with open("%s/detectors/reauth.py" % cls.dirname, "w") as f:
+            auditor = dedent("""
+              from roundup.cgi.exceptions import Reauth
+
+              def trigger_reauth(db, cl, nodeid, newvalues):
+                  if 'realname' in newvalues and not hasattr(db, 'reauth_done'):
+                      raise Reauth('Add an optional message to the user')
+
+              def init(db):
+                  db.user.audit('set', trigger_reauth, priority=110)
+             """)
+            f.write(auditor)
+
         # open the database
         cls.db = cls.instance.open('admin')
 
@@ -116,6 +131,12 @@
         cls.db.user.create(username="fred", roles='User',
             password=password.Password('sekrit'), address='fred@example.com')
 
+        # add a user for reauth tests
+        cls.db.user.create(username="reauth",
+                           realname="reauth test user",
+                           password=password.Password("reauth"),
+                           address="reauth@example.com", roles="User")
+
         # set the url the test instance will run at.
         cls.db.config['TRACKER_WEB'] = cls.tracker_web
         # set up mailhost so errors get reported to debuging capture file
@@ -123,6 +144,9 @@
         cls.db.config.MAIL_HOST = "localhost"
         cls.db.config.MAIL_DEBUG = "../_test_tracker_mail.log"
 
+        # also report it in the web.
+        cls.db.config.WEB_DEBUG = "yes"
+
         # added to enable csrf forgeries/CORS to be tested
         cls.db.config.WEB_CSRF_ENFORCE_HEADER_ORIGIN = "required"
         cls.db.config.WEB_ALLOWED_API_ORIGINS = "https://client.com"
@@ -336,6 +360,171 @@
        wsgi server is started with various feature flags
     """
 
+    def test_reauth_workflow(self):
+        """as admin user:
+             change reauth user realname include all fields on the form
+                 also add a dummy file to the submitted request.
+             get back a reauth page/template (look for id="reauth_form")
+             verify hidden input for realname
+             verify hidden input for roles
+             verify the base 64 file content are on the page.
+        
+             submit form with bad password
+             verify error reported
+             verify hidden input for realname
+             (note the file contents will be gone because
+               preserving that requires javascript)
+        
+             enter good password
+             verify on user page (look for
+                              "(the default is 0)" hint for timezone)
+             verify new name present
+             verify success banner
+        """
+        from html.parser import HTMLParser
+        class HTMLExtractForm(HTMLParser):
+            """Custom parser to extract input fields from a form.
+
+               Set the form_label to extract inputs only inside a form
+               with a name or id matching form_label. Default is
+               "reauth_form".
+
+               Set names to a tuple/list/set with the names of the
+               inputs you are interested in. Defalt is None which
+               extracts all inputs on the page with a name property.
+            """
+            def __init__(self, names=None, form_label="reauth_form"):
+                super().__init__()
+                self.fields = {}
+                self.names = names
+                self.form_label = form_label
+                self._inside_form = False
+                
+            def handle_starttag(self, tag, attrs):
+                if tag == 'form':
+                    for attr, value in attrs:
+                        if attr in ('id', 'name') and value == self.form_label:
+                            self._inside_form = True
+                            return
+
+                if not self._inside_form: return
+                
+                if tag == 'input':
+                    field_name = None
+                    field_value = None
+                    for attr, value in attrs:
+                        if attr == 'name':
+                            field_name = value
+                        if attr == 'value':
+                            field_value = value
+
+                    # skip input type="submit" without name
+                    if not field_name: return
+                    
+                    if self.names is None:
+                        self.fields[field_name] = field_value
+                    elif field_name in self.names:
+                        self.fields[field_name] = field_value
+
+            def handle_endtag(self, tag):
+                if tag == "form":
+                    self._inside_form = False
+                    
+            def get_fields(self):
+                return self.fields
+
+
+        user_url = "%s/user%s" % (self.url_base(),
+                                  self.db.user.lookup('reauth'))
+
+        session, _response = self.create_login_session()
+        
+        user_page = session.get(user_url)
+        
+        self.assertEqual(user_page.status_code, 200)
+        self.assertTrue(b'reauth' in user_page.content)
+
+        parser = HTMLExtractForm(('@lastactivity', '@csrf'), 'itemSynopsis')
+        parser.feed(user_page.text)
+                                 
+        change = {"realname": "reauth1",
+                  "username": "reauth",
+                  "password": "",
+                  "@confirm@password": "",
+                  "phone": "",
+                  "organisation": "",
+                  "roles": "User",
+                  "timezone": "",
+                  "address": "reauth@example.com",
+                  "alternate_addresses": "",
+                  "@template": "item",
+                  "@required": "username,address",
+                  "@submit_button": "Submit Changes",
+                  "@action": "edit",
+                  **parser.get_fields()
+                  }
+        lastactivity = parser.get_fields()['@lastactivity']
+
+        # make the simple name/value dict into a name/tuple dict
+        # setting tuple[0] to None to indicate pulre string
+        # value. Then we use change2 with file to trigger
+        # multipart/form-data form encoding which preserves fields
+        # with empty values. application/x-www-form-urlencoded forms
+        # have fields with empty values dropped by cgi by default.
+        userpage_change = {key: (None, value) for key, value in change.items()}
+        userpage_change.update({"@file": ("filename.txt", "this is some text")})
+        
+        on_reauth = session.post(user_url, files=userpage_change)
+
+        self.assertIn(b'id="reauth_form"', on_reauth.content)
+        self.assertIn(b'Please enter your password to continue with',
+                      on_reauth.content)
+        # make sure the base64 encoded content for @file is present on
+        # the page. Because we are not running a javascript capable
+        # browser, it is not converted into an actual file input.
+        # But this check shows that a file generated by reauth is trying
+        # to maintain the file input.
+        self.assertIn(b'dGhpcyBpcyBzb21lIHRleHQ=', on_reauth.content)
+
+        parser = HTMLExtractForm()
+        parser.feed(on_reauth.text)
+        fields = parser.get_fields()
+        self.assertEqual(fields["@lastactivity"], lastactivity)
+        self.assertEqual(fields["@next_action"], "edit")
+        self.assertEqual(fields["@action"], "reauth")
+        self.assertEqual(fields["address"], "reauth@example.com")
+        self.assertEqual(fields["phone"], "")
+        self.assertEqual(fields["roles"], "User")
+        self.assertEqual(fields["realname"], "reauth1")
+
+        reauth_fields = {
+            "@reauth_password": (None, "sekret not right"),
+            "submit": (None, " Authorize Change "),
+            }
+        reauth_submit = {key: (None, value) for key, value in fields.items()}
+        reauth_submit.update(reauth_fields)
+
+        fail_reauth = session.post(user_url,
+                                   files=reauth_submit)
+        self.assertIn(b'id="reauth_form"', fail_reauth.content)
+        self.assertIn(b'Please enter your password to continue with',
+                      fail_reauth.content)
+        self.assertIn(b'Password incorrect', fail_reauth.content)
+
+        parser = HTMLExtractForm(('@csrf',))
+        parser.feed(fail_reauth.text)
+        # remeber we are logged in as admin - use admin pw.
+        reauth_submit.update({"@reauth_password": (None, "sekrit"),
+                              "@csrf":
+                                (None, parser.get_fields()['@csrf'])})
+        pass_reauth = session.post(user_url,
+                                   files=reauth_submit)
+        self.assertNotIn(b'id="reauth_form"', pass_reauth.content)
+        self.assertNotIn(b'Please enter your password to continue with',
+                      pass_reauth.content)
+        self.assertIn(b'user 4 realname edited ok', pass_reauth.content)
+        self.assertIn(b'(the default is 0)', pass_reauth.content)
+        
     def test_cookie_attributes(self):
         session, _response = self.create_login_session()
 

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