Repository: privacyidea/privacyidea

Issue:
New SMPP SMS provider
# Why SMPP SMS provider is necessary for me?

I need to send SMS via own sms center. I can use chain http provider + http-smpp gate (kannel), but it is not easy, service depend from additional daemon .

# Solution

I develop my own SMS provider, it works for me. I think, sharing it may be useful.

# Requirements

SMS provider requires **smpplib**, so you need install it via pip

Regards,
Alexander



Discussion:

Resolving patch:
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
@@ -41,8 +41,8 @@
 SMS_PROVIDERS = [
     "privacyidea.lib.smsprovider.HttpSMSProvider.HttpSMSProvider",
     "privacyidea.lib.smsprovider.SipgateSMSProvider.SipgateSMSProvider",
-    "privacyidea.lib.smsprovider.SmtpSMSProvider.SmtpSMSProvider"]
-
+    "privacyidea.lib.smsprovider.SmtpSMSProvider.SmtpSMSProvider",
+    "privacyidea.lib.smsprovider.SmppSMSProvider.SmppSMSProvider"]
 
 class SMSError(Exception):
     def __init__(self, error_id, description):
diff --git a/privacyidea/lib/smsprovider/SmppSMSProvider.py b/privacyidea/lib/smsprovider/SmppSMSProvider.py
new file mode 100644
--- /dev/null
+++ b/privacyidea/lib/smsprovider/SmppSMSProvider.py
@@ -0,0 +1,143 @@
+# -*- coding: utf-8 -*-
+#
+#    2017-12-27 Cornelius Kölbel <cornelius.koelbel@netknights.i>
+#               Restructuring Code.
+#
+#    E-mail: yurlov.alexandr@gmail.com
+#
+#    2017-12-22 Alexander Yurlov <yurlov.alexandr@gmail.com>
+#               Add new SMPP SMS provider
+#
+#    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 SMPP protocol to SMS center
+It requires smpplib installation, this lib works with ascii only, but message support unicode 
+
+"""
+
+from privacyidea.lib.smsprovider.SMSProvider import (ISMSProvider, SMSError)
+from privacyidea.lib import _
+from privacyidea.lib.utils import parse_int
+import logging
+import traceback
+log = logging.getLogger(__name__)
+
+try:
+    import smpplib
+    import_successful = True
+except ImportError:     # pragma: no cover
+    log.warning("Failed to import smpplib.")
+    import_successful = False
+
+
+class SmppSMSProvider(ISMSProvider):
+
+    def submit_message(self, phone, message):
+        """
+        send a message to a phone via an smpp protocol to smsc
+
+        :param phone: the phone number
+        :param message: the message to submit to the phone
+        :return:
+        """
+        if not import_successful:  # pragma: no cover
+            log.error("smpplib can not be found!")
+            raise SMSError(404, "smpplib can not be found!")
+
+        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!")
+
+        smsc_host = self.smsgateway.option_dict.get("SMSC_HOST")
+        smsc_port = self.smsgateway.option_dict.get("SMSC_PORT")
+        sys_id = self.smsgateway.option_dict.get("SYSTEM_ID")
+        passwd = self.smsgateway.option_dict.get("PASSWORD")
+        s_addr_ton = parse_int(self.smsgateway.option_dict.get("S_ADDR_TON"))
+        s_addr_npi = parse_int(self.smsgateway.option_dict.get("S_ADDR_NPI"))
+        s_addr = self.smsgateway.option_dict.get("S_ADDR")
+        d_addr_ton = parse_int(self.smsgateway.option_dict.get("D_ADDR_TON"))
+        d_addr_npi = parse_int(self.smsgateway.option_dict.get("D_ADDR_NPI"))
+
+        if not smsc_host:
+            log.warning("Can not submit message. SMSC_HOST is missing.")
+            raise SMSError(-1, "No SMSC_HOST specified in the provider config.")
+
+        if not smsc_port:
+            log.warning("Can not submit message. SMSC_PORT is missing.")
+            raise SMSError(-1, "No SMSC_PORT specified in the provider config.")
+
+        # Initialize the SMPP Client
+        client = None
+        error_message = None 
+        try:
+            client = smpplib.client.Client(smsc_host.encode("ascii"),
+                                           smsc_port.encode("ascii"))
+            client.connect()
+            client.bind_transmitter(system_id=sys_id.encode("ascii"),
+                                    password=passwd.encode("ascii"))
+            client.send_message(source_addr_ton=s_addr_ton,
+                                source_addr_npi=s_addr_npi,
+                                source_addr=s_addr.encode("ascii"),
+                                dest_addr_ton=d_addr_ton,
+                                dest_addr_npi=d_addr_npi,
+                                destination_addr=phone.encode("ascii"),
+                                short_message=message.encode("ascii"))
+        except Exception as err:
+            error_message = "{0!r}".format(err)
+            log.warning("Failed to send message: {0!r}".format(error_message))
+            log.debug("{0!s}".format(traceback.format_exc()))
+
+        finally:
+            if client:
+                client.disconnect()
+
+        if error_message:
+            raise SMSError(error_message, "SMS could not be "
+                                          "sent: {0!r}".format(error_message))
+        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": {
+                        "SMSC_HOST": {
+                            "required": True,
+                            "description": _("SMSC Host IP")},
+                        "SMSC_PORT": {
+                            "required": True,
+                            "description": _("SMSC Port")},
+                        "SYSTEM_ID": {
+                            "description": _("SMSC Service ID")},
+                        "PASSWORD": {
+                            "description": _("Password for authentication on SMSC")},
+                        "S_ADDR_TON": {
+                            "description": _("SOURCE_ADDR_TON Special Flag")},
+                        "S_ADDR_NPI": {
+                            "description": _("S_ADDR_NPI Special Flag")},
+                        "S_ADDR": {
+                            "description": _("Source address (SMS sender)")},
+                        "D_ADDR_TON": {"description": _("DESTINATION_ADDR_TON Special Flag")},
+                        "D_ADDR_NPI": {"description": _("D_ADDR_NPI Special Flag")}
+                    }
+                    }
+        return params
diff --git a/privacyidea/lib/utils.py b/privacyidea/lib/utils.py
--- a/privacyidea/lib/utils.py
+++ b/privacyidea/lib/utils.py
@@ -1075,3 +1075,26 @@ def check_password(self, pw, stored_hash):
             hx = self.crypt_private(pw, stored_hash)
         return stored_hash == hx
 
+
+def parse_int(s, default=0):
+    """
+    Returns an integer either to base10 or base16.
+    :param s: A possible string given.
+    :param default: If the value can not be parsed or is None, return this
+        default value
+    :return: An integer
+    """
+    i = default
+    try:
+        i = int(s)
+        return i
+    except (ValueError, TypeError):
+        pass
+
+    try:
+        i = int(s, 16)
+        return i
+    except (ValueError, TypeError):
+        pass
+
+    return i


Test patch:
diff --git a/tests/smppmock.py b/tests/smppmock.py
new file mode 100644
--- /dev/null
+++ b/tests/smppmock.py
@@ -0,0 +1,203 @@
+# -*- coding: utf-8 -*-
+"""
+2017-12-27 Cornelius Kölbel <cornelius.koelbel@netknights.it>
+           Mocking the SMPP client
+
+
+Original responses.py is:
+Copyright 2013 Dropbox, Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+from __future__ import (
+    absolute_import, print_function, division, unicode_literals
+)
+
+import six
+
+
+try:
+    from six import cStringIO as BufferIO
+except ImportError:
+    from six import StringIO as BufferIO
+
+import inspect
+from collections import namedtuple, Sequence, Sized
+from functools import update_wrapper
+from smpplib.client import ConnectionError
+
+
+Call = namedtuple('Call', ['request', 'response'])
+
+_wrapper_template = """\
+def wrapper%(signature)s:
+    with smppmock:
+        return func%(funcargs)s
+"""
+
+
+def get_wrapped(func, wrapper_template, evaldict):
+    # Preserve the argspec for the wrapped function so that testing
+    # tools such as pytest can continue to use their fixture injection.
+    args, a, kw, defaults = inspect.getargspec(func)
+    values = args[-len(defaults):] if defaults else None
+
+    signature = inspect.formatargspec(args, a, kw, defaults)
+    is_bound_method = hasattr(func, '__self__')
+    if is_bound_method:
+        args = args[1:]     # Omit 'self'
+    callargs = inspect.formatargspec(args, a, kw, values,
+                                     formatvalue=lambda v: '=' + v)
+
+    ctx = {'signature': signature, 'funcargs': callargs}
+    six.exec_(wrapper_template % ctx, evaldict)
+
+    wrapper = evaldict['wrapper']
+
+    update_wrapper(wrapper, func)
+    if is_bound_method:
+        wrapper = wrapper.__get__(func.__self__, type(func.__self__))
+    return wrapper
+
+
+class CallList(Sequence, Sized):
+    def __init__(self):
+        self._calls = []
+
+    def __iter__(self):
+        return iter(self._calls)
+
+    def __len__(self):
+        return len(self._calls)
+
+    def __getitem__(self, idx):
+        return self._calls[idx]
+
+    def setdata(self, request, response):
+        self._calls.append(Call(request, response))
+
+    def reset(self):
+        self._calls = []
+
+
+class SmppMock(object):
+
+    def __init__(self):
+        self._calls = CallList()
+        self.connect_successful = True
+        self.systemid = None
+        self.password = None
+        self.reset()
+
+    def reset(self):
+        self._calls.reset()
+
+    def setdata(self, connection_success=True, systemid=None, password=None):
+        self.connect_successful = connection_success
+        self.systemid = systemid
+        self.password = password
+
+    @property
+    def calls(self):
+        return self._calls
+
+    def __enter__(self):
+        self.start()
+
+    def __exit__(self, *args):
+        self.stop()
+        self.reset()
+
+    def activate(self, func):
+        evaldict = {'smppmock': self, 'func': func}
+        return get_wrapped(func, _wrapper_template, evaldict)
+
+    def _on_connect(self, SMPP):
+        if self.connect_successful:
+            return None
+        else:
+            raise ConnectionError()
+
+    def _on_disconnect(self, SMPP):
+        self.host = None
+        self.port = None
+
+    def _on_init(self, SMPP, host, port):
+        self.host = host
+        self.port = port
+        return None
+
+    def _on_transmitter(self, SMPP, systemid, password):
+        if systemid != self.systemid or password != self.password:
+            raise Exception("Wrong credentials")
+        return None
+
+    def _on_send_message(self, SMPP, source_addr_ton=None, source_addr_npi=None,
+                         source_addr=None, dest_addr_ton=None,
+                         dest_addr_npi=None, destination_addr=None,
+                         short_message=None):
+        pass
+
+    def start(self):
+        import mock
+
+        def unbound_on_init(SMPP, host, port):
+            return self._on_init(SMPP, host, port)
+
+        self._patcher1 = mock.patch('smpplib.client.Client.__init__',
+                                   unbound_on_init)
+        self._patcher1.start()
+
+        def unbound_on_connect(SMPP):
+            return self._on_connect(SMPP)
+
+        self._patcher2 = mock.patch('smpplib.client.Client.connect',
+                                    unbound_on_connect)
+        self._patcher2.start()
+
+        def unbound_on_transmitter(SMPP, system_id, password):
+            return self._on_transmitter(SMPP, system_id, password)
+
+        self._patcher3 = mock.patch('smpplib.client.Client.bind_transmitter',
+                                    unbound_on_transmitter)
+        self._patcher3.start()
+
+        def unbound_on_send_message(SMPP, *a, **kwargs):
+            return self._on_send_message(SMPP, *a, **kwargs)
+
+        self._patcher4 = mock.patch('smpplib.client.Client.send_message',
+                                    unbound_on_send_message)
+        self._patcher4.start()
+
+        def unbound_on_disconnect(SMPP):
+            return self._on_disconnect(SMPP)
+
+        self._patcher5 = mock.patch('smpplib.client.Client.disconnect',
+                                    unbound_on_disconnect)
+        self._patcher5.start()
+
+    def stop(self):
+        self._patcher1.stop()
+        self._patcher2.stop()
+        self._patcher3.stop()
+        self._patcher4.stop()
+        self._patcher5.stop()
+
+
+# expose default mock namespace
+mock = _default_mock = SmppMock()
+__all__ = []
+for __attr in (a for a in dir(_default_mock) if not a.startswith('_')):
+    __all__.append(__attr)
+    globals()[__attr] = getattr(_default_mock, __attr)
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
@@ -181,22 +181,27 @@ def test_04_sms_provider_modules(self):
             res = self.app.full_dispatch_request()
             self.assertTrue(res.status_code == 200, res)
             value = json.loads(res.data).get("result").get("value")
-            self.assertEqual(len(value), 3)
+            self.assertEqual(len(value), 4)
             self.assertTrue('privacyidea.lib.smsprovider.HttpSMSProvider'
                             '.HttpSMSProvider' in value)
             self.assertTrue('privacyidea.lib.smsprovider.SmtpSMSProvider'
                             '.SmtpSMSProvider' in value)
             self.assertTrue('privacyidea.lib.smsprovider.SipgateSMSProvider'
                             '.SipgateSMSProvider' in value)
+            self.assertTrue('privacyidea.lib.smsprovider.SmppSMSProvider'
+                            '.SmppSMSProvider' in value)
             http_parameters = value.get('privacyidea.lib.smsprovider.'
                                         'HttpSMSProvider.HttpSMSProvider')
             smtp_parameters = value.get('privacyidea.lib.smsprovider.'
                                         'SmtpSMSProvider.SmtpSMSProvider')
             sipgate_parameters = value.get('privacyidea.lib.smsprovider.'
                                         'SipgateSMSProvider.SipgateSMSProvider')
+            smpp_parameters = value.get('privacyidea.lib.smsprovider.'
+                                        'SmppSMSProvider.SmppSMSProvider')
             self.assertEqual(http_parameters.get("options_allowed"), True)
             self.assertEqual(smtp_parameters.get("options_allowed"), False)
             self.assertEqual(sipgate_parameters.get("options_allowed"), False)
+            self.assertEqual(smpp_parameters.get("options_allowed"), False)
             self.assertTrue("URL" in http_parameters.get("parameters"))
             self.assertTrue("PROXY" in http_parameters.get("parameters"))
             self.assertTrue("HTTP_METHOD" in http_parameters.get("parameters"))
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
@@ -4,6 +4,7 @@
  lib.smsprovider.httpsmsprovider
  lib.smsprovider.sipgateprovider
  lib.smsprovider.smtpsmsprovider
+ lib.smsprovider.smppsmsprovider
 """
 
 from .base import MyTestCase
@@ -11,6 +12,7 @@
 from privacyidea.lib.smsprovider.SipgateSMSProvider import SipgateSMSProvider
 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.SMSProvider import (SMSError,
                                                      get_sms_provider_class,
                                                      set_smsgateway,
@@ -21,6 +23,7 @@
 from privacyidea.lib.smtpserver import add_smtpserver
 import responses
 import smtpmock
+import smppmock
 
 
 class SMSTestCase(MyTestCase):
@@ -46,6 +49,10 @@ def test_01_get_provider_class(self):
         _provider =get_sms_provider_class(
             "privacyidea.lib.smsprovider.SmtpSMSProvider",
             "SmtpSMSProvider")
+        
+        _provider =get_sms_provider_class(
+            "privacyidea.lib.smsprovider.SmppSMSProvider",
+            "SmppSMSProvider")
 
         # A non-existing module will raise an error
         self.assertRaises(Exception,
@@ -442,3 +449,77 @@ def test_11_send_nonascii_sms_post_success(self):
         # Here we need to send the SMS
         r = self.post_provider.submit_message("123456", u"Hallöle Smørrebrød")
         self.assertTrue(r)
+
+
+class SmppSMSTestCase(MyTestCase):
+
+    config = {'SMSC_HOST': "192.168.1.1",
+              'SMSC_PORT': "1234",
+              'SYSTEM_ID': "privacyIDEA",
+              'PASSWORD': "secret",
+              'SYSTEM_ID': "privacyIDEA",
+              'S_ADDR_TON': "0x5",
+              'S_ADDR_NPI': "0x1",
+              'S_ADDR': "privacyIDEA",
+              'D_ADDR_TON': "0x5",
+              'D_ADDR_NPI': "0x1"}
+    provider_module = "privacyidea.lib.smsprovider.SmppSMSProvider" \
+                      ".SmppSMSProvider"
+
+    def setUp(self):
+
+        # Use a the gateway definition for configuring the provider
+        identifier = "mySmppGW"
+        id = set_smsgateway(identifier, self.provider_module, description="test",
+                            options=self.config)
+        self.assertTrue(id > 0)
+        self.provider = create_sms_instance(identifier=identifier)
+        self.assertEqual(type(self.provider), SmppSMSProvider)
+
+    def test_00_config(self):
+        r = SmppSMSProvider.parameters()
+        self.assertEqual(r.get("options_allowed"), False)
+        params = r.get("parameters")
+        self.assertEqual(params.get("SMSC_HOST").get("required"), True)
+
+    def test_00_errors(self):
+        # No smsgateway defined
+        s = SmppSMSProvider()
+        self.assertRaises(SMSError, s.submit_message, "phone", "message")
+
+        # No host defined
+        set_smsgateway("missing_host", self.provider_module,
+                       options={"SMSC_PORT": "1234"})
+        p = create_sms_instance(identifier="missing_host")
+        self.assertRaises(SMSError, p.submit_message, "phone", "message")
+        delete_smsgateway("missing_host")
+
+        # No port defined
+        set_smsgateway("missing_port", self.provider_module,
+                       options={"SMSC_HOST": "1.1.1.1"})
+        p = create_sms_instance(identifier="missing_port")
+        self.assertRaises(SMSError, p.submit_message, "phone", "message")
+        delete_smsgateway("missing_port")
+
+    @smppmock.activate
+    def test_01_success(self):
+        # Here we need to send the SMS
+        smppmock.setdata(connection_success=True,
+                         systemid="privacyIDEA",
+                         password="secret")
+        r = self.provider.submit_message("123456", "Hello")
+        self.assertTrue(r)
+
+    @smppmock.activate
+    def test_02_fail_connection(self):
+        smppmock.setdata(connection_success=False,
+                         systemid="privacyIDEA",
+                         password="secret")
+        self.assertRaises(SMSError, self.provider.submit_message, "123456", "hello")
+
+    @smppmock.activate
+    def test_03_fail_wrong_credentials(self):
+        smppmock.setdata(connection_success=True,
+                         systemid="privacyIDEA",
+                         password="wrong")
+        self.assertRaises(SMSError, self.provider.submit_message, "123456", "hello")
diff --git a/tests/test_lib_utils.py b/tests/test_lib_utils.py
--- a/tests/test_lib_utils.py
+++ b/tests/test_lib_utils.py
@@ -13,7 +13,7 @@
                                    parse_time_offset_from_now,
                                    parse_time_delta, to_unicode,
                                    hash_password, PasswordHash, check_ssha,
-                                   check_sha, otrs_sha256)
+                                   check_sha, otrs_sha256, parse_int)
 from datetime import timedelta, datetime
 from netaddr import IPAddress, IPNetwork, AddrFormatError
 from dateutil.tz import tzlocal, tzoffset
@@ -394,3 +394,19 @@ def test_15_hash_passwords(self):
         p_hash = hash_password("passw0rd", "ssha512")
         self.assertTrue(check_ssha(p_hash, "passw0rd", hashlib.sha512, 64))
         self.assertFalse(check_ssha(p_hash, "password", hashlib.sha512, 64))
+
+    def test_16_parse_int(self):
+        r = parse_int("xxx", 12)
+        self.assertEqual(r, 12)
+        r = parse_int("ABC", 11)
+        self.assertEqual(r, 2748)
+        r = parse_int("ABCX", 11)
+        self.assertEqual(r, 11)
+        r = parse_int(123)
+        self.assertEqual(r, 123)
+        r = parse_int(0x12)
+        self.assertEqual(r, 18)
+        r = parse_int("0x12")
+        self.assertEqual(r, 18)
+        r = parse_int("123")
+        self.assertEqual(r, 123)