Repository: privacyidea/privacyidea

Issue:
SMS Token reads phone number from user source on each request
In certain scenarios it can be helpful that the phone number is *not* fixed in the SMS token during enrollment (this is currently for security resons).

The phone number should be read from the user source on each authentication request.

# Implementation consideration

We can enhance the existing SMS token using a boolean switch:

     [x] Read phone number from user source on each auth request (no fixed phone number)

## limitations

The user could have more than one phone number in the user source (list field in LDAP).
We simply would use the first phone number. The LDAP hast to take care, that there is only one (the right) number, as the LDAP also defines the phone number. THis would be out of scope of privacyIDEA.


Discussion:
At this point we get the phone number from the token info:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/tokens/smstoken.py#L343

We could add an additional tokeninfo like: "use_dynamic_phone".

Then we could use the tokenclass **property** ``self.user``
to get the user_object and fetch the userinformation including the mobile phone number.

    phone = self.user.get_user_phone("mobile")



Resolving patch:
diff --git a/privacyidea/lib/tokens/emailtoken.py b/privacyidea/lib/tokens/emailtoken.py
--- a/privacyidea/lib/tokens/emailtoken.py
+++ b/privacyidea/lib/tokens/emailtoken.py
@@ -1,4 +1,6 @@
 # -*- coding: utf-8 -*-
+#  2018-02-16 Cornelius Kölbel <cornelius.koelbel@netknights.it>
+#             Add dynamic email address. Dynamically read from user source.
 #  2018-01-21 Cornelius Kölbel <cornelius.koelbel@netknights.it>
 #             Add email templates
 #  2015-12-29 Cornelius Kölbel <cornelius.koelbel@netknights.it>
@@ -65,6 +67,7 @@
 from privacyidea.lib.tokens.smstoken import HotpTokenClass
 from privacyidea.lib.config import get_from_config
 from privacyidea.api.lib.utils import getParam
+from privacyidea.lib.utils import is_true
 from privacyidea.lib.policy import SCOPE
 from privacyidea.lib.log import log_with
 from privacyidea.lib import _
@@ -101,7 +104,16 @@ def __init__(self, aToken):
 
     @property
     def _email_address(self):
-        return self.get_tokeninfo(self.EMAIL_ADDRESS_KEY)
+        if is_true(self.get_tokeninfo("dynamic_email")):
+            email = self.user.info.get(self.EMAIL_ADDRESS_KEY)
+            if type(email) == list and email:
+                # If there is a non-empty list, we use the first entry
+                email = email[0]
+        else:
+            email = self.get_tokeninfo(self.EMAIL_ADDRESS_KEY)
+        if not email:  # pragma: no cover
+            log.warning("Token {0!s} does not have an email address!".format(self.token.serial))
+        return email
 
     @_email_address.setter
     def _email_address(self, value):
@@ -179,10 +191,13 @@ def update(self, param, reset_failcount=True):
         :return: nothing
 
         """
-        # specific - e-mail
-        self._email_address = getParam(param,
-                                       self.EMAIL_ADDRESS_KEY,
-                                       optional=False)
+        if getParam(param, "dynamic_email", optional=True):
+            self.add_tokeninfo("dynamic_email", True)
+        else:
+            # specific - e-mail
+            self._email_address = getParam(param,
+                                           self.EMAIL_ADDRESS_KEY,
+                                           optional=False)
 
         # in case of the e-mail token, only the server must know the otpkey
         # thus if none is provided, we let create one (in the TokenClass)
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
 #
+#  2018-02-16   Cornelius Kölbel <cornelius.koelbel@netknights.it>
+#               Allow to use a dynamic_phone
 #  2016-06-20   Cornelius Kölbel <cornelius.koelbel@netkngihts.it>
 #               Use sms.identifier, central SMS gateway definition, to send
 #               the OTP value via SMS.
@@ -45,7 +47,8 @@
 import traceback
 
 from privacyidea.api.lib.utils import getParam
-from privacyidea.api.lib.utils import required
+from privacyidea.api.lib.utils import required, optional
+from privacyidea.lib.utils import is_true
 
 from privacyidea.lib.config import get_from_config
 from privacyidea.lib.policy import SCOPE
@@ -222,9 +225,12 @@ def update(self, param, reset_failcount=True):
         :type param: dict
         :return: nothing
         """
-        # specific - phone
-        phone = getParam(param, "phone", required)
-        self.add_tokeninfo("phone", phone)
+        if getParam(param, "dynamic_phone", optional):
+            self.add_tokeninfo("dynamic_phone", True)
+        else:
+            # specific - phone
+            phone = getParam(param, "phone", required)
+            self.add_tokeninfo("phone", phone)
 
         # in case of the sms token, only the server must know the otpkey
         # thus if none is provided, we let create one (in the TokenClass)
@@ -338,9 +344,15 @@ def _send_sms(self, message="<otp>"):
         :return: submitted message
         :rtype: string
         """
-        ret = None
-
-        phone = self.get_tokeninfo("phone")
+        if is_true(self.get_tokeninfo("dynamic_phone")):
+            phone = self.user.get_user_phone("mobile")
+            if type(phone) == list and phone:
+                # if there is a non-empty list, we use the first entry
+                phone = phone[0]
+        else:
+            phone = self.get_tokeninfo("phone")
+        if not phone:  # pragma: no cover
+            log.warning("Token {0!s} does not have a phone number!".format(self.token.serial))
         otp = self.get_otp()[2]
         serial = self.get_serial()
 


Test patch:
diff --git a/tests/test_lib_tokens_email.py b/tests/test_lib_tokens_email.py
--- a/tests/test_lib_tokens_email.py
+++ b/tests/test_lib_tokens_email.py
@@ -18,6 +18,7 @@
 import datetime
 from dateutil.tz import tzlocal
 import smtpmock
+import mock
 
 
 class EmailTokenTestCase(MyTestCase):
@@ -32,6 +33,7 @@ class EmailTokenTestCase(MyTestCase):
     realm1 = "realm1"
     realm2 = "realm2"
     serial1 = "SE123456"
+    serial2 = "SE000000"
     otpkey = "3132333435363738393031323334353637383930"
 
     success_body = "ID 12345"
@@ -74,6 +76,22 @@ def test_01_create_token(self):
         self.assertTrue(class_prefix == "PIEM", class_prefix)
         self.assertTrue(token.get_class_type() == "email", token)
 
+        # create token with dynamic email
+        db_token = Token(self.serial2, tokentype="email")
+        db_token.save()
+        token = EmailTokenClass(db_token)
+        token.update({"dynamic_email": True})
+        token.save()
+        self.assertEqual(token.get_tokeninfo("dynamic_email"), "1")
+        self.assertTrue(token.token.serial == self.serial2, token)
+        self.assertTrue(token.token.tokentype == "email", token.token)
+        self.assertTrue(token.type == "email", token.type)
+        class_prefix = token.get_class_prefix()
+        self.assertTrue(class_prefix == "PIEM", class_prefix)
+        self.assertTrue(token.get_class_type() == "email", token)
+        token.set_user(User(login="cornelius",
+                            realm=self.realm1))
+
     def test_02_set_user(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
         token = EmailTokenClass(db_token)
@@ -336,6 +354,38 @@ def test_18_challenge_request(self):
         r = token.check_challenge_response(passw=otp)
         self.assertTrue(r, r)
 
+    @smtpmock.activate
+    def test_18a_challenge_request_dynamic(self):
+        smtpmock.setdata(response={"pi_tester@privacyidea.org": (200, 'OK')})
+        transactionid = "123456098712"
+        # send the email with the old configuration
+        set_privacyidea_config("email.mailserver", "localhost")
+        set_privacyidea_config("email.username", "user")
+        set_privacyidea_config("email.username", "password")
+        set_privacyidea_config("email.tls", True)
+        db_token = Token.query.filter_by(serial=self.serial2).first()
+        token = EmailTokenClass(db_token)
+        self.assertTrue(token.check_otp("123456", 1, 10) == -1)
+        c = token.create_challenge(transactionid)
+        self.assertTrue(c[0], c)
+        otp = c[1]
+        self.assertTrue(c[3].get("state"), transactionid)
+
+        # check for the challenges response
+        r = token.check_challenge_response(passw=otp)
+        self.assertTrue(r, r)
+
+    def test_18b_challenge_request_dynamic_multivalue(self):
+        db_token = Token.query.filter_by(serial=self.serial2).first()
+        token = EmailTokenClass(db_token)
+        # if the email is a multi-value attribute, the first address should be chosen
+        new_user_info = token.user.info.copy()
+        new_user_info['email'] = ['email1@example.com', 'email2@example.com']
+        with mock.patch('privacyidea.lib.resolvers.PasswdIdResolver.IdResolver.getUserInfo') as mock_user_info:
+            mock_user_info.return_value = new_user_info
+            self.assertEqual(token._email_address, 'email1@example.com')
+
+
     @smtpmock.activate
     def test_19_emailtext(self):
         # create a EMAILTEXT policy:
diff --git a/tests/test_lib_tokens_sms.py b/tests/test_lib_tokens_sms.py
--- a/tests/test_lib_tokens_sms.py
+++ b/tests/test_lib_tokens_sms.py
@@ -13,6 +13,7 @@
 from privacyidea.lib.config import (set_privacyidea_config, set_prepend_pin)
 from privacyidea.lib.policy import set_policy, SCOPE, PolicyClass
 import datetime
+import mock
 import responses
 
 
@@ -28,6 +29,7 @@ class SMSTokenTestCase(MyTestCase):
     realm1 = "realm1"
     realm2 = "realm2"
     serial1 = "SE123456"
+    serial2 = "SE222222"
     otpkey = "3132333435363738393031323334353637383930"
 
     SMSHttpUrl = "http://smsgateway.com/sms_send_api.cgi"
@@ -85,6 +87,21 @@ def test_01_create_token(self):
         self.assertTrue(class_prefix == "PISM", class_prefix)
         self.assertTrue(token.get_class_type() == "sms", token)
 
+        db_token = Token(self.serial2, tokentype="sms")
+        db_token.save()
+        token = SmsTokenClass(db_token)
+        token.update({"dynamic_phone": True})
+        token.save()
+        self.assertTrue(token.token.serial == self.serial2, token)
+        self.assertTrue(token.token.tokentype == "sms", token.token)
+        self.assertEqual(token.get_tokeninfo("dynamic_phone"), "1")
+        self.assertTrue(token.type == "sms", token.type)
+        class_prefix = token.get_class_prefix()
+        self.assertTrue(class_prefix == "PISM", class_prefix)
+        self.assertTrue(token.get_class_type() == "sms", token)
+        token.set_user(User(login="cornelius",
+                            realm=self.realm1))
+
     def test_02_set_user(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
         token = SmsTokenClass(db_token)
@@ -366,6 +383,47 @@ def test_18_challenge_request(self):
                                                         transactionid})
         self.assertTrue(r, r)
 
+    @responses.activate
+    def test_18a_challenge_request_dynamic(self):
+        # Send a challenge request for an SMS token with a dynamic phone number
+        responses.add(responses.POST,
+                      self.SMSHttpUrl,
+                      body=self.success_body)
+        transactionid = "123456098712"
+        set_privacyidea_config("sms.providerConfig", self.SMSProviderConfig)
+        db_token = Token.query.filter_by(serial=self.serial2).first()
+        token = SmsTokenClass(db_token)
+        self.assertTrue(token.check_otp("123456", 1, 10) == -1)
+        c = token.create_challenge(transactionid)
+        self.assertTrue(c[0], c)
+        otp = c[1]
+        self.assertTrue(c[3].get("state"), transactionid)
+
+        # check for the challenges response
+        r = token.check_challenge_response(passw=otp,
+                                           options={"transaction_id":
+                                                        transactionid})
+        self.assertTrue(r, r)
+
+    @responses.activate
+    def test_18b_challenge_request_dynamic_multivalue(self):
+        responses.add(responses.POST,
+                      self.SMSHttpUrl,
+                      body=self.success_body)
+        transactionid = "123456098712"
+        set_privacyidea_config("sms.providerConfig", self.SMSProviderConfig)
+        db_token = Token.query.filter_by(serial=self.serial2).first()
+        token = SmsTokenClass(db_token)
+        # if the email is a multi-value attribute, the first address should be chosen
+        new_user_info = token.user.info.copy()
+        new_user_info['mobile'] = ['1234', '5678']
+        with mock.patch('privacyidea.lib.resolvers.PasswdIdResolver.IdResolver.getUserInfo') as mock_user_info:
+            mock_user_info.return_value = new_user_info
+            c = token.create_challenge(transactionid)
+            self.assertTrue(c[0], c)
+            self.assertIn('destination=1234', responses.calls[0].request.body)
+            self.assertNotIn('destination=5678', responses.calls[0].request.body)
+
     @responses.activate
     def test_19_smstext(self):
         # create a SMSTEXT policy: