Repository: privacyidea/privacyidea

Issue:
ScriptSMSProvider 
Some SMS gateways have unuasual APIs.
Having a ScriptSMSProvider would allow use to write arbitrary scripts that communitcate to the SMS Gateway.

A script would be called with the parameters:

* phone
* message

The ScriptSMSProvider needs these configuration values:

* Script to run
* run in forground or background.


Discussion:

Resolving patch:
diff --git a/privacyidea/lib/policy.py b/privacyidea/lib/policy.py
--- a/privacyidea/lib/policy.py
+++ b/privacyidea/lib/policy.py
@@ -671,8 +671,10 @@ def match_policies(self, name=None, scope=None, realm=None, active=None,
                             "{1!s} - {2!s}@{3!s} in resolver {4!s}".format((name, scope, action),
                                                                            user_object, user, realm, resolver))
                 log.warning("Possible programming error: {0!s}".format(tb_str))
-                raise ParameterError("Cannot pass user_object as well as user, resolver, realm "
-                                     "in policy {0!s}".format((name, scope, action)))
+                raise ParameterError("Cannot pass user_object ({1!s}) as well as user ({2!s}),"
+                                     " resolver ({3!s}), realm ({4!s})"
+                                     "in policy {0!s}".format((name, scope, action), user_object,
+                                                              user, resolver, realm))
             user = user_object.login
             realm = user_object.realm
             resolver = user_object.resolver
diff --git a/privacyidea/lib/smsprovider/SMSProvider.py b/privacyidea/lib/smsprovider/SMSProvider.py
--- a/privacyidea/lib/smsprovider/SMSProvider.py
+++ b/privacyidea/lib/smsprovider/SMSProvider.py
@@ -45,7 +45,8 @@
     "privacyidea.lib.smsprovider.SipgateSMSProvider.SipgateSMSProvider",
     "privacyidea.lib.smsprovider.SmtpSMSProvider.SmtpSMSProvider",
     "privacyidea.lib.smsprovider.SmppSMSProvider.SmppSMSProvider",
-    "privacyidea.lib.smsprovider.FirebaseProvider.FirebaseProvider"]
+    "privacyidea.lib.smsprovider.FirebaseProvider.FirebaseProvider",
+    "privacyidea.lib.smsprovider.ScriptSMSProvider.ScriptSMSProvider"]
 
 
 class SMSError(Exception):
diff --git a/privacyidea/lib/smsprovider/ScriptSMSProvider.py b/privacyidea/lib/smsprovider/ScriptSMSProvider.py
new file mode 100644
--- /dev/null
+++ b/privacyidea/lib/smsprovider/ScriptSMSProvider.py
@@ -0,0 +1,122 @@
+# -*- coding: utf-8 -*-
+#
+#    2020-06-04 Cornelius Kölbel <cornelius.koelbel@netknights.i>
+#               Initial Code
+#
+#    This program is free software: you can redistribute it and/or
+#    modify it under the terms of the GNU Affero General Public
+#    License, version 3, as published by the Free Software Foundation.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU Affero General Public License for more details.
+#
+#    You should have received a copy of the
+#               GNU Affero General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+__doc__ = """This is the SMSClass to send SMS via a script.
+"""
+
+from privacyidea.lib.smsprovider.SMSProvider import (ISMSProvider, SMSError)
+from privacyidea.lib import _
+from privacyidea.lib.framework import get_app_config_value
+import subprocess
+import logging
+import traceback
+log = logging.getLogger(__name__)
+
+
+SCRIPT_BACKGROUND = "background"
+SCRIPT_WAIT = "wait"
+
+
+class ScriptSMSProvider(ISMSProvider):
+
+    def __init__(self, db_smsprovider_object=None, smsgateway=None, directory=None):
+        """
+        Create a new SMS Provider object fom a DB SMS provider object
+
+        :param db_smsprovider_object: The database object
+        :param smsgateway: The SMS gateway object from the database table
+            SMS gateway. The options can be accessed via
+            self.smsgateway.option_dict
+        :param directory: The directory where the SMS sending scripts are located.
+        :type directory: str
+        :return: An SMS provider object
+        """
+        self.config = db_smsprovider_object or {}
+        self.smsgateway = smsgateway
+        self.script_directory = directory or get_app_config_value("PI_SCRIPT_SMSPROVIDER_DIRECTORY",
+                                                                  "/etc/privacyidea/scripts")
+
+    def submit_message(self, phone, message):
+        """
+        send a message to a phone using an external script
+
+        :param phone: the phone number
+        :param message: the message to submit to the phone
+        :return:
+        """
+        log.debug("submitting message {0!s} to {1!s}".format(message, phone))
+        if not self.smsgateway:
+            # this should not happen. We now always use sms gateway definitions.
+            log.warning("Missing smsgateway definition!")
+            raise SMSError(-1, "Missing smsgateway definition!")
+
+        script = self.smsgateway.option_dict.get("script")
+        background = self.smsgateway.option_dict.get("background")
+
+        script_name = self.script_directory + "/" + script
+        proc_args = [script_name]
+        proc_args.append(phone)
+
+        # As the message can contain blanks... it is passed via stdin
+        rcode = 0
+        try:
+            log.info("Starting script {script!r}.".format(script=script_name))
+            p = subprocess.Popen(proc_args, cwd=self.script_directory, universal_newlines=True, stdin=subprocess.PIPE)
+            p.communicate(message)
+            if background == SCRIPT_WAIT:
+                rcode = p.wait()
+        except Exception as e:
+            log.warning("Failed to execute script {0!r}: {1!r}".format(
+                script_name, e))
+            log.warning(traceback.format_exc())
+            if background == SCRIPT_WAIT:
+                raise SMSError(-1, "Failed to start script for sending SMS.")
+
+        if rcode:
+            log.warning("Script {script!r} failed to execute with error code {error!r}".format(script=script_name,
+                                                                                               error=rcode))
+            raise SMSError(-1, "Error during execution of the script.")
+        else:
+            log.info("SMS delivered to {0!s}.".format(phone))
+
+        return True
+
+    @classmethod
+    def parameters(cls):
+        """
+        Return a dictionary, that describes the parameters and options for the
+        SMS provider.
+        Parameters are required keys to values.
+
+        :return: dict
+        """
+        params = {"options_allowed": False,
+                  "parameters": {
+                      "script": {
+                          "required": True,
+                          "description": _("The script in script directory PI_SCRIPT_SMSPROVIDER_DIRECTORY to call. "
+                                           "Expects phone as the parameter and the message from stdin.")
+                      },
+                      "background": {
+                          "required": True,
+                          "description": _("Wait for script to complete or run script in background. This will "
+                                           "either return the HTTP request early or could also block the request."),
+                          "values": [SCRIPT_BACKGROUND, SCRIPT_WAIT]}
+                    }
+                  }
+        return params


Test patch:
diff --git a/tests/test_api_smsgateway.py b/tests/test_api_smsgateway.py
--- a/tests/test_api_smsgateway.py
+++ b/tests/test_api_smsgateway.py
@@ -186,7 +186,7 @@ def test_04_sms_provider_modules(self):
             res = self.app.full_dispatch_request()
             self.assertTrue(res.status_code == 200, res)
             value = res.json.get("result").get("value")
-            self.assertEqual(len(value), 5)
+            self.assertEqual(len(value), 6)
             self.assertTrue('privacyidea.lib.smsprovider.HttpSMSProvider'
                             '.HttpSMSProvider' in value)
             self.assertTrue('privacyidea.lib.smsprovider.SmtpSMSProvider'
@@ -195,6 +195,8 @@ def test_04_sms_provider_modules(self):
                             '.SipgateSMSProvider' in value)
             self.assertTrue('privacyidea.lib.smsprovider.SmppSMSProvider'
                             '.SmppSMSProvider' in value)
+            self.assertIn('privacyidea.lib.smsprovider.ScriptSMSProvider'
+                          '.ScriptSMSProvider', value)
             http_parameters = value.get('privacyidea.lib.smsprovider.'
                                         'HttpSMSProvider.HttpSMSProvider')
             smtp_parameters = value.get('privacyidea.lib.smsprovider.'
diff --git a/tests/test_lib_smsprovider.py b/tests/test_lib_smsprovider.py
--- a/tests/test_lib_smsprovider.py
+++ b/tests/test_lib_smsprovider.py
@@ -1,3 +1,4 @@
+
 # -*- coding: utf-8 -*-
 __doc__="""
 This test file tests the modules:
@@ -5,6 +6,7 @@
  lib.smsprovider.sipgateprovider
  lib.smsprovider.smtpsmsprovider
  lib.smsprovider.smppsmsprovider
+ lib.smsprovider.scriptsmsprovider
 """
 
 from .base import MyTestCase
@@ -13,6 +15,7 @@
 from privacyidea.lib.smsprovider.SipgateSMSProvider import URL
 from privacyidea.lib.smsprovider.SmtpSMSProvider import SmtpSMSProvider
 from privacyidea.lib.smsprovider.SmppSMSProvider import SmppSMSProvider
+from privacyidea.lib.smsprovider.ScriptSMSProvider import ScriptSMSProvider, SCRIPT_WAIT
 from privacyidea.lib.smsprovider.SMSProvider import (SMSError,
                                                      get_sms_provider_class,
                                                      set_smsgateway,
@@ -22,6 +25,7 @@
                                                      create_sms_instance)
 from privacyidea.lib.smtpserver import add_smtpserver
 import responses
+import os
 from . import smtpmock
 from . import smppmock
 
@@ -269,6 +273,66 @@ def test_08_smsgateway_success(self):
         self.assertTrue(r)
 
 
+class ScriptSMSTestCase(MyTestCase):
+
+    directory = "{0!s}/tests/testdata/scripts/".format(os.getcwd())
+
+    def test_01_fail_no_script(self):
+        # The script does not exist
+        identifier = "myScriptSMS"
+        config = {"background": SCRIPT_WAIT,
+                  "script": "sms-script-does-not-exist.sh"}
+        provider_module = "privacyidea.lib.smsprovider.ScriptSMSProvider" \
+                          ".ScriptSMSProvider"
+        id = set_smsgateway(identifier, provider_module, description="test",
+                            options=config)
+        self.assertTrue(id > 0)
+        sms = ScriptSMSProvider(smsgateway=get_smsgateway(identifier)[0], directory=self.directory)
+        self.assertRaises(SMSError, sms.submit_message, "123456", "Hello")
+        delete_smsgateway(identifier)
+        
+        # We bail out, fi no smsgateway definition is given!
+        sms = ScriptSMSProvider(directory=self.directory)
+        self.assertRaises(SMSError, sms.submit_message, "123456", "Hello")
+
+
+    def test_02_success(self):
+        # the script runs successfully
+        identifier = "myScriptSMS"
+        config = {"background": SCRIPT_WAIT,
+                  "script": "success.sh"}
+        provider_module = "privacyidea.lib.smsprovider.ScriptSMSProvider" \
+                          ".ScriptSMSProvider"
+        id = set_smsgateway(identifier, provider_module, description="test",
+                            options=config)
+        self.assertTrue(id > 0)
+        sms = ScriptSMSProvider(smsgateway=get_smsgateway(identifier)[0], directory=self.directory)
+        r = sms.submit_message("123456", "Hello")
+        self.assertTrue(r)
+        delete_smsgateway(identifier)
+
+    def test_02_fail(self):
+        # The script returns a failing rcode
+        identifier = "myScriptSMS"
+        config = {"background": SCRIPT_WAIT,
+                  "script": "fail.sh"}
+        provider_module = "privacyidea.lib.smsprovider.ScriptSMSProvider" \
+                          ".ScriptSMSProvider"
+        id = set_smsgateway(identifier, provider_module, description="test",
+                            options=config)
+        self.assertTrue(id > 0)
+        sms = ScriptSMSProvider(smsgateway=get_smsgateway(identifier)[0], directory=self.directory)
+        self.assertRaises(SMSError, sms.submit_message, "123456", "Hello")
+        delete_smsgateway(identifier)
+
+    def test_03_parameters(self):
+        # check parameters
+        params = ScriptSMSProvider.parameters()
+        self.assertFalse(params.get("options_allowed"))
+        self.assertIn("script", params.get("parameters"))
+        self.assertIn("background", params.get("parameters"))
+
+
 class HttpSMSTestCase(MyTestCase):
 
     post_url = "http://smsgateway.com/sms_send_api.cgi"
diff --git a/tests/testdata/scripts/success.sh b/tests/testdata/scripts/success.sh
new file mode 100755
--- /dev/null
+++ b/tests/testdata/scripts/success.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+exit 0