Repository: privacyidea/privacyidea

Issue:
Allow different SMS Gateways for SMS tokens
Currently the SMS gateway is defined centraly in the system config. This *one* SMS gateway is then used for all SMS tokens in the system.

It should be possible to choose different SMS gateways for tokens. 
The Admin (or user) should be able to choose the SMS gateway that should be used for this token.
(We also should add a policy, which SMS gateway the admin or the user is allowed to choose).

During initialization of the SMS token the API call could simply add a parameter ``sms.identifier`` and use ``self.get_tokeninfo("sms.identifier")`` to read the individual SMS gateway.
If no individual SMS gateway is set, the default, system wide gateway should be used.


Discussion:

Resolving patch:
diff --git a/privacyidea/api/lib/prepolicy.py b/privacyidea/api/lib/prepolicy.py
--- a/privacyidea/api/lib/prepolicy.py
+++ b/privacyidea/api/lib/prepolicy.py
@@ -300,6 +300,53 @@ def check_otp_pin(request=None, action=None):
     return True
 
 
+def sms_identifiers(request=None, action=None):
+    """
+    This is a token specific wrapper for sms tokens
+    to be used with the endpoint /token/init.
+    According to the policy scope=SCOPE.ADMIN or scope=SCOPE.USER
+    action=SMSACTION.GATEWAYS the sms.identifier is only allowed to be set to the listed gateways.
+
+    :param request:
+    :param action:
+    :return:
+    """
+    sms_identifier = request.all_data.get("sms.identifier")
+    if sms_identifier:
+        from privacyidea.lib.tokens.smstoken import SMSACTION
+        role = g.logged_in_user.get("role")
+        if role == ROLE.USER:
+            scope = SCOPE.USER
+            username = g.logged_in_user.get("username")
+            realm = g.logged_in_user.get("realm")
+            adminrealm = None
+        else:
+            scope = SCOPE.ADMIN
+            username = g.logged_in_user.get("username")
+            realm = getParam(request.all_data, "realm")
+            adminrealm = g.logged_in_user.get("realm")
+
+        policy_object = g.policy_object
+        pols = policy_object.get_action_values(
+            action=SMSACTION.GATEWAYS,
+            scope=scope,
+            user=username,
+            realm=realm,
+            adminrealm=adminrealm,
+            client=g.client_ip,
+            unique=False,
+            audit_data=g.audit_object.audit_data)
+        gateway_identifiers = []
+
+        for p in pols:
+            gateway_identifiers.append(p)
+        if sms_identifier not in gateway_identifiers:
+            log.warning("{0!s} not in {1!s}".format(sms_identifier, gateway_identifiers))
+            raise PolicyError("The requested sms.identifier is not allowed to be enrolled.")
+
+    return True
+
+
 def papertoken_count(request=None, action=None):
     """
     This is a token specific wrapper for paper token for the endpoint
diff --git a/privacyidea/api/token.py b/privacyidea/api/token.py
--- a/privacyidea/api/token.py
+++ b/privacyidea/api/token.py
@@ -89,7 +89,8 @@
                                            tantoken_count,
                                            u2ftoken_allowed, u2ftoken_verify_cert,
                                            twostep_enrollment_activation,
-                                           twostep_enrollment_parameters)
+                                           twostep_enrollment_parameters,
+                                           sms_identifiers)
 from privacyidea.api.lib.postpolicy import (save_pin_change,
                                             postpolicy)
 from privacyidea.lib.event import event
@@ -126,6 +127,7 @@
 @prepolicy(check_external, request, action="init")
 @prepolicy(init_token_defaults, request)
 @prepolicy(papertoken_count, request)
+@prepolicy(sms_identifiers, request)
 @prepolicy(tantoken_count, request)
 @prepolicy(u2ftoken_allowed, request)
 @prepolicy(u2ftoken_verify_cert, request)
diff --git a/privacyidea/lib/tokens/smstoken.py b/privacyidea/lib/tokens/smstoken.py
--- a/privacyidea/lib/tokens/smstoken.py
+++ b/privacyidea/lib/tokens/smstoken.py
@@ -5,6 +5,8 @@
 #  License:  AGPLv3
 #  contact:  http://www.privacyidea.org
 #
+#  2019-01-14   Cornelius Kölbel <cornelius.koelbel@netknights.it>
+#               Allow different SMS gateways via "sms.identifier"
 #  2018-10-31   Cornelius Kölbel <cornelius.koelbel@netknights.it>
 #               Let the client choose to get a HTTP 500 Error code if
 #               SMS fails.
@@ -57,7 +59,8 @@
 from privacyidea.lib.policy import SCOPE, ACTION, get_action_values_from_options
 from privacyidea.lib.log import log_with
 from privacyidea.lib.smsprovider.SMSProvider import (get_sms_provider_class,
-                                                     create_sms_instance)
+                                                     create_sms_instance,
+                                                     get_smsgateway)
 from json import loads
 from privacyidea.lib import _
 
@@ -77,6 +80,7 @@
 class SMSACTION(object):
     SMSTEXT = "smstext"
     SMSAUTO = "smsautosend"
+    GATEWAYS = "sms_gateways"
 
 
 class SmsTokenClass(HotpTokenClass):
@@ -187,7 +191,7 @@ def get_class_info(key=None, ret='all'):
         :return: subsection if key exists or user defined
         :rtype : s.o.
         """
-
+        sms_gateways = [gw.identifier for gw in get_smsgateway()]
         res = {'type': 'sms',
                'title': _('SMS Token'),
                'description':
@@ -213,7 +217,23 @@ def get_class_info(key=None, ret='all'):
                            'desc': _('Use an alternate challenge text for telling the '
                                      'user to enter the code from the SMS.')
                        }
-                   }
+                   },
+                   SCOPE.ADMIN: {
+                       SMSACTION.GATEWAYS: {
+                           'type': 'str',
+                           'desc': u"{0!s} ({1!s})".format(
+                               _('Choose the gateways the administrator is allowed to set.'),
+                               " ".join(sms_gateways))
+                       }
+                   },
+                   SCOPE.USER: {
+                       SMSACTION.GATEWAYS: {
+                           'type': 'str',
+                           'desc': u"{0!s} ({1!s})".format(
+                               _('Choose the gateways the user is allowed to set.'),
+                               " ".join(sms_gateways))
+                       }
+                   },
                },
         }
 
@@ -375,7 +395,8 @@ def _send_sms(self, message="<otp>"):
         log.debug("sending SMS to phone number {0!s} ".format(phone))
 
         # First we try to get the new SMS gateway config style
-        sms_gateway_identifier = get_from_config("sms.identifier")
+        # The token specific identifier has priority over the system wide identifier
+        sms_gateway_identifier = self.get_tokeninfo("sms.identifier") or get_from_config("sms.identifier")
 
         if sms_gateway_identifier:
             # New style


Test patch:
diff --git a/tests/test_api_lib_policy.py b/tests/test_api_lib_policy.py
--- a/tests/test_api_lib_policy.py
+++ b/tests/test_api_lib_policy.py
@@ -25,7 +25,7 @@
                                            required_email, auditlog_age,
                                            papertoken_count, allowed_audit_realm,
                                            u2ftoken_verify_cert,
-                                           tantoken_count)
+                                           tantoken_count, sms_identifiers)
 from privacyidea.api.lib.postpolicy import (check_serial, check_tokentype,
                                             check_tokeninfo,
                                             no_detail_on_success,
@@ -39,6 +39,7 @@
 from privacyidea.lib.user import User
 from privacyidea.lib.tokens.papertoken import PAPERACTION
 from privacyidea.lib.tokens.tantoken import TANACTION
+from privacyidea.lib.tokens.smstoken import SMSACTION
 
 from flask import Response, Request, g, current_app, jsonify
 from werkzeug.test import EnvironBuilder
@@ -1266,6 +1267,52 @@ def test_21_u2f_verify_cert(self):
         # finally delete policy
         delete_policy("polu2f1")
 
+    def test_01_sms_identifier(self):
+        # every admin is allowed to enroll sms token with gw1 or gw2
+        set_policy("sms1", scope=SCOPE.ADMIN, action="{0!s}=gw1 gw2".format(SMSACTION.GATEWAYS))
+        set_policy("sms2", scope=SCOPE.ADMIN, action="{0!s}=gw3".format(SMSACTION.GATEWAYS))
+
+        g.logged_in_user = {"username": "admin1",
+                            "role": "admin"}
+        builder = EnvironBuilder(method='POST',
+                                 data={'serial': "SMS1234"},
+                                 headers={})
+        env = builder.get_environ()
+        req = Request(env)
+        req.User = User()
+        req.all_data = {"sms.identifier": "gw1"}
+        g.policy_object = PolicyClass()
+        r = sms_identifiers(req)
+        self.assertTrue(r)
+
+        delete_policy("sms1")
+        delete_policy("sms2")
+        g.policy_object = PolicyClass()
+        # No policy set, the request will fail
+        self.assertRaises(PolicyError, sms_identifiers, req)
+
+        # Users are allowed to choose gw4
+        set_policy("sms1", scope=SCOPE.USER, action="{0!s}=gw4".format(SMSACTION.GATEWAYS))
+
+        g.logged_in_user = {"username": "hans",
+                            "role": "user"}
+        builder = EnvironBuilder(method='POST',
+                                 data={'serial': "SMS1234"},
+                                 headers={})
+        env = builder.get_environ()
+        req = Request(env)
+        req.User = User()
+        req.all_data = {"sms.identifier": "gw4"}
+        g.policy_object = PolicyClass()
+        r = sms_identifiers(req)
+        self.assertTrue(r)
+
+        # Now the user tries gw1
+        req.all_data = {"sms.identifier": "gw1"}
+        self.assertRaises(PolicyError, sms_identifiers, req)
+
+        delete_policy("sms1")
+
 
 class PostPolicyDecoratorTestCase(MyTestCase):