Repository: privacyidea/privacyidea

Issue:
Enhance Notification Handler to write information to a file in spool directory
The enrollment tab for HOTP and TOTP token could provide a letter template, when the QR Code is enrolled.

A policy could define the template file.
The template file could read contact data from the user source.
The created QR Code letter should be editable.


Discussion:
Usually this does not need to be a manual process, but it is rather automated.

And in this case the script event handler could be used to send data to a printing center after enrollment of a HOTP/TOTP token.
We could however enhance the ``UserNotification Hander`` to also write files to disk.

Using an html template file.

The handler would have to be enhanced with data from the "detail" section. Thus we could add the *otpkey* in the printer file.
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/eventhandler/usernotification.py#L280

We could add the QR code like this:
<pre>
"""
Your qr code is

&lt;div class=center&gt;
{detail[googleurl][img]}
&lt;/div&gt;
""".format(detail=detail)
</pre>
This would work without any policies.

The notification handler would be enhanced like this:

~~~~python
actions = {"writeletter": { "filename":
                                             {"type": "str",
                                              "required": True,
                                              "description": _("can contain tags")},
                                     "body": {"type": "text",
                                                  "required": True,
                                                  "description": _("can contain tags")},
                                },
~~~~

Any other process could fetch this pin-letter or QR-Code letter.
Relates to #1138 
In addition to "sendsms", "sendemail", this could be a third action in the notifcation handler "savefile".

We would still have a body, that defines the file. The body could contain html or csv with the usual tags.

Each event writes a new file with a temporary/random file name (with a prefix/timestamp/...?)

Additional options:
* Spool directory

We have a functionality in the email auth policy. https://privacyidea.readthedocs.io/en/latest/policies/authentication.html?highlight=file%3A%2F#emailtext that allows to specify a file template.
But I think this is only due to the fact, that the policy action field is so limited.

On the other hand in the NotificationHander in the action "sendemail" we have the "body", that is not read from a file but from the event handler definition. The [event handler option](https://github.com/privacyidea/privacyidea/blob/master/privacyidea/models.py#L1880) and thus the file template is 2000 bytes long.
I think this is enough and to make it working in the same way like the email, I would simply put the template into a body like with "sendemail".

~~~~python
 actions = {"sendmail": {....},
                  "sendsms": {...},
                  "savefile": {"template": {"type": "text",
                                         "required": True,
                                         "description": _("Template of the file with python tags")},
                                     "spooldirectory": {"type": "str"},
                                     "filename_prefix": {str},
                                     "filename_suffix": {str},
~~~~


I will not use a template file!
If it is necessary to create files that are much bigger than the 2K in the database for the text template,
the file written could very well be simply a csv file and another system level parser could generate bigger files in a second step.

Resolving patch:
diff --git a/privacyidea/config.py b/privacyidea/config.py
--- a/privacyidea/config.py
+++ b/privacyidea/config.py
@@ -50,6 +50,7 @@ class TestingConfig(Config):
     PI_GNUPG_HOME = "tests/testdata/gpg"
     CACHE_TYPE = "None"
     PI_SCRIPT_HANDLER_DIRECTORY = "tests/testdata/scripts/"
+    PI_NOTIFICATION_HANDLER_SPOOLDIRECTORY = "tests/testdata/"
     PI_NODE = "Node1"
     PI_NODES = ["Node2"]
     PI_ENGINE_REGISTRY_CLASS = "null"
diff --git a/privacyidea/lib/eventhandler/usernotification.py b/privacyidea/lib/eventhandler/usernotification.py
--- a/privacyidea/lib/eventhandler/usernotification.py
+++ b/privacyidea/lib/eventhandler/usernotification.py
@@ -48,9 +48,10 @@
 from privacyidea.lib.smsprovider.SMSProvider import get_smsgateway
 from privacyidea.lib.user import User, get_user_list
 from privacyidea.lib.utils import create_tag_dict
+from privacyidea.lib.crypto import get_alphanum_str
 from privacyidea.lib import _
 import logging
-import datetime
+import os
 
 log = logging.getLogger(__name__)
 
@@ -182,7 +183,20 @@ def actions(cls):
                                       "description": _("Send notification to "
                                                        "this user."),
                                       "value": [NOTIFY_TYPE.TOKENOWNER]}
-                               }
+                               },
+                   "savefile": {"body":
+                                    {"type": "text",
+                                     "required": True,
+                                     "description": _("This is the template content of "
+                                                      "the new file. Can contain the tags "
+                                                      "as specified in the documentation.")},
+                                "filename":
+                                    {"type": "str",
+                                     "required": True,
+                                     "description": _("The filename of the notification. Existing files "
+                                                      "are overwritten. The name can contain tags as specified "
+                                                      "in the documentation and can also contain the tag {random}.")}
+                   }
                    }
         return actions
 
@@ -275,7 +289,8 @@ def do(self, action, options=None):
             log.warning("Was not able to determine the recipient for the user "
                         "notification: {0!s}".format(handler_def))
 
-        if recipient:
+        if recipient or action.lower() == "savefile":
+            # In case of "savefile" we do not need a recipient
             # Collect all data
             body = handler_options.get("body") or DEFAULT_BODY
             subject = handler_options.get("subject") or \
@@ -335,6 +350,22 @@ def do(self, action, options=None):
                     log.warning("Failed to send a notification email to user "
                                 "{0}".format(recipient))
 
+            elif action.lower() == "savefile":
+                spooldir = get_app_config_value("PI_NOTIFICATION_HANDLER_SPOOLDIRECTORY",
+                                                "/var/lib/privacyidea/notifications/")
+                filename = handler_options.get("filename")
+                random = get_alphanum_str(16)
+                filename = filename.format(random=random, **tags).lstrip(os.path.sep)
+                outfile = os.path.normpath(os.path.join(spooldir, filename))
+                if not outfile.startswith(spooldir):
+                    log.error(u'Cannot write outside of spooldir {0!s}!'.format(spooldir))
+                else:
+                    try:
+                        with open(outfile, "w") as f:
+                            f.write(body)
+                    except Exception as err:
+                        log.error(u"Failed to write notification file: {0!s}".format(err))
+
             elif action.lower() == "sendsms":
                 smsconfig = handler_options.get("smsconfig")
                 userphone = recipient.get("mobile")


Test patch:
diff --git a/tests/test_lib_events.py b/tests/test_lib_events.py
--- a/tests/test_lib_events.py
+++ b/tests/test_lib_events.py
@@ -6,6 +6,8 @@
 """
 import email
 
+import mock
+
 from . import smtpmock
 import responses
 import os
@@ -2867,4 +2869,89 @@ def test_20_sendmail_googleurl(self):
                          "/sURO8OBzXevLvx18NL9d8/P43gefKq6glpIgTr4pFgn/Dn7pA/qmRLRbt1ywi3d13S6k+p1wp"
                          "+3XDkB2rJw5zQH69SZz3LYibxO+PMW3uS5VFeH1yP1Hm01ZSplFmVY4c9bk1dLNo2QlhJpnvRsTFVi4bfi7o+3dFYdq"
                          "/WkLtlMlRmhOmz+GasLf1G8qRLTOevId47pLMNQv9mXF/418O+ewd6UT+qJE/XozhhQUYYV"
-                         "/qx91rBTVg5VvjaVkxgjVr1O+BUz/fc64cKFCxcuXLjw/wX+HzgPbUakdjuaAAAAAElFTkSuQmCC' />")
\ No newline at end of file
+                         "/qx91rBTVg5VvjaVkxgjVr1O+BUz/fc64cKFCxcuXLjw/wX+HzgPbUakdjuaAAAAAElFTkSuQmCC' />")
+
+    def test_21_save_notification(self):
+        g = FakeFlaskG()
+        audit_object = FakeAudit()
+        g.logged_in_user = {"username": "admin",
+                            "role": "admin",
+                            "realm": ""}
+        g.audit_object = audit_object
+
+        builder = EnvironBuilder(method='POST',
+                                 data={'serial': "OATH123456"},
+                                 headers={})
+
+        env = builder.get_environ()
+        # Set the remote address so that we can filter for it
+        env["REMOTE_ADDR"] = "10.0.0.1"
+        g.client_ip = env["REMOTE_ADDR"]
+        req = Request(env)
+        req.all_data = {"serial": "OATH123456",
+                        "user": "cornelius"}
+        req.User = User("cornelius", self.realm1)
+        resp = Response()
+        resp.data = """{"result": {"value": true}}"""
+        options = {"g": g,
+                   "request": req,
+                   "response": resp,
+                   "handler_def": {"options":
+                                       {"filename": "test{serial}.txt",
+                                        "body": "{serial}, {user}"}
+                                   }
+                   }
+        # remove leftover file from the last test run, if any
+        if os.path.exists("tests/testdata/testOATH123456.txt"):
+            os.remove("tests/testdata/testOATH123456.txt")
+        un_handler = UserNotificationEventHandler()
+        res = un_handler.do("savefile", options=options)
+        self.assertTrue(res)
+        # check, if the file was written with the correct contents
+        with open("tests/testdata/testOATH123456.txt") as f:
+            l = f.read()
+        self.assertEqual(l, "OATH123456, Cornelius")
+        os.remove("tests/testdata/testOATH123456.txt")
+
+        # Check what happens if we try to write outside of spooldir
+        options = {"g": g,
+                   "request": req,
+                   "response": resp,
+                   "handler_def": {"options":
+                                       {"filename": "../../../test{serial}.txt",
+                                        "body": "{serial}, {user}"}
+                                   }
+                   }
+
+        un_handler = UserNotificationEventHandler()
+        # Check that an error is written to the logfile
+        with mock.patch("logging.Logger.error") as mock_log:
+            un_handler.do("savefile", options=options)
+            mock_log.assert_called_once_with("Cannot write outside of spooldir tests/testdata/!")
+
+        # Check what happens if the file can not be written
+        options = {"g": g,
+                   "request": req,
+                   "response": resp,
+                   "handler_def": {"options":
+                                       {"filename": "test{serial}.txt",
+                                        "body": "{serial}, {user}"}
+                                   }
+                   }
+
+        # create a file, that is not writable
+        with open("tests/testdata/testOATH123456.txt", "w") as f:
+            f.write("empty")
+        os.chmod("tests/testdata/testOATH123456.txt", 0o400)
+        un_handler = UserNotificationEventHandler()
+        # Check that an error is written to the logfile
+        with mock.patch("logging.Logger.error") as mock_log:
+            un_handler.do("savefile", options=options)
+            call_args = mock_log.call_args
+            # ensure log.error was actually called ...
+            self.assertIsNotNone(call_args)
+            # ... with the right message
+            self.assertTrue(call_args[0][0].startswith("Failed to write notification file:"))
+
+        os.remove("tests/testdata/testOATH123456.txt")
+