Repository: privacyidea/privacyidea

Issue:
Variables in PUSH Notifications
**Is your feature request related to a problem? Please describe.**
Show the clients remote IP address trying to access resources in the PUSH notification.

**Describe the solution you'd like**
The variable that allows you to include custom text to also access certain variables.

**Describe alternatives you've considered**
I don't think there is an alternative.

**Additional context**
Discussion - https://community.privacyidea.org/t/variables-in-push-notification-text/

# Implementation considerations

Variables would be used in the policy settings MOBILE_TEXT and MOBILE_TITLE.
See https://github.com/privacyidea/privacyidea/blob/900b7250e41ff5a5eecfe89cd3f274a8f9df7595/privacyidea/lib/tokens/pushtoken.py#L349

Currently variables are also used e.g. in *smstext*. See https://privacyidea.readthedocs.io/en/v3.9/policies/authentication.html#smstext

The text is created this way:
https://github.com/privacyidea/privacyidea/blob/9c2bcdb7080460152841eb241d415bfd1e43389c/privacyidea/lib/tokens/smstoken.py#L429

The function `create_tag_dict` also knows the client_ip. But the client_ip usually is the IP of the requesting application talking to privacyIDEA.

This code
https://github.com/privacyidea/privacyidea/blob/900b7250e41ff5a5eecfe89cd3f274a8f9df7595/privacyidea/lib/tokens/pushtoken.py#L189

could be probably simply enhanced like this:

~~~~Python
    message_on_mobile = get_action_values_from_options(SCOPE.AUTH,
                                                       PUSH_ACTION.MOBILE_TEXT,
                                                       options) or DEFAULT_MOBILE_TEXT

    tags = create_tag_dict(serial=serial,
                           tokenowner=User,
                           tokentype="sms",
                           recipient={"givenname": User.info.get("givenname") if User else "",
                                      "surname": User.info.get("surname") if User else ""},
                           challenge=options.get("challenge"))
    message_on_mobile = message_on_mobile.format(**tags)
~~~~



Discussion:

Resolving patch:
diff --git a/privacyidea/lib/tokens/pushtoken.py b/privacyidea/lib/tokens/pushtoken.py
--- a/privacyidea/lib/tokens/pushtoken.py
+++ b/privacyidea/lib/tokens/pushtoken.py
@@ -38,7 +38,7 @@
 from privacyidea.api.lib.utils import getParam
 from privacyidea.api.lib.policyhelper import get_pushtoken_add_config
 from privacyidea.lib.token import get_one_token, init_token
-from privacyidea.lib.utils import prepare_result, to_bytes, is_true
+from privacyidea.lib.utils import prepare_result, to_bytes, is_true, create_tag_dict
 from privacyidea.lib.error import (ResourceNotFoundError, ValidateError,
                                    privacyIDEAError, ConfigAdminError, PolicyError)
 
@@ -169,10 +169,12 @@ def create_push_token_url(url=None, ttl=10, issuer="privacyIDEA", serial="mylabe
                                        extra=_construct_extra_parameters(extra_data)))
 
 
-def _build_smartphone_data(serial, challenge, registration_url, pem_privkey, options):
+def _build_smartphone_data(token_obj, challenge, registration_url, pem_privkey, options):
     """
-    Create the dictionary to be send to the smartphone as challenge
+    Create the dictionary to be sent to the smartphone as challenge
 
+    :param token_obj: The token object for which to create the smartphone data
+    :type token_obj: A tokenclass object
     :param challenge: base32 encoded random data string
     :type challenge: str
     :param registration_url: The privacyIDEA URL, to which the Push token communicates
@@ -189,11 +191,40 @@ def _build_smartphone_data(serial, challenge, registration_url, pem_privkey, opt
     message_on_mobile = get_action_values_from_options(SCOPE.AUTH,
                                                        PUSH_ACTION.MOBILE_TEXT,
                                                        options) or DEFAULT_MOBILE_TEXT
+    # Get the request object
+    _g = options.get("g", {})
+    req_headers = None
+    request = None
+    if hasattr(_g, "request_headers"):
+        req_headers = _g.request_headers
+    if req_headers:
+        req_environment = req_headers.environ
+        request = req_environment.get("werkzeug.request")
+    if request:
+        user_object = request.User
+    else:
+        # Get owner from token object
+        try:
+            user_object = token_obj.user
+        except Exception:
+            user_object = None
+
+    tags = create_tag_dict(serial=token_obj.token.serial,
+                           request=request,
+                           client_ip=options.get("clientip"),
+                           tokenowner=user_object,
+                           tokentype="push",
+                           recipient={"givenname": user_object.info.get("givenname") if user_object else "",
+                                      "surname": user_object.info.get("surname") if user_object else ""},
+                           challenge=options.get("challenge"))
+    message_on_mobile = message_on_mobile.format(**tags)
+    log.debug("Sending to mobile: {0!s}".format(message_on_mobile))
+
     title = get_action_values_from_options(SCOPE.AUTH, PUSH_ACTION.MOBILE_TITLE,
                                            options) or "privacyIDEA"
     smartphone_data = {"nonce": challenge,
                        "question": message_on_mobile,
-                       "serial": serial,
+                       "serial": token_obj.token.serial,
                        "title": title,
                        "sslverify": sslverify,
                        "url": registration_url}
@@ -348,7 +379,8 @@ def get_class_info(key=None, ret='all'):
                    SCOPE.AUTH: {
                        PUSH_ACTION.MOBILE_TEXT: {
                            'type': 'str',
-                           'desc': _('The question the user sees on his mobile phone.'),
+                           'desc': _('The question the user sees on his mobile phone. Several tags like {serial} and '
+                                     '{client_ip} can be used as parameters.'),
                            'group': 'PUSH'
                        },
                        PUSH_ACTION.MOBILE_TITLE: {
@@ -707,7 +739,7 @@ def _api_endpoint_get(cls, g, request_data):
                 if not answered and chal.is_valid():
                     # then return the necessary smartphone data to answer
                     # the challenge
-                    sp_data = _build_smartphone_data(serial, chal.challenge,
+                    sp_data = _build_smartphone_data(tok, chal.challenge,
                                                      registration_url, pem_privkey, options)
                     challenges.append(sp_data)
             # return the challenges as a list in the result value
@@ -870,7 +902,7 @@ def create_challenge(self, transactionid=None, options=None):
                     registration_url = get_action_values_from_options(
                         SCOPE.ENROLL, PUSH_ACTION.REGISTRATION_URL, options=options)
                     pem_privkey = self.get_tokeninfo(PRIVATE_KEY_SERVER)
-                    smartphone_data = _build_smartphone_data(self.token.serial,
+                    smartphone_data = _build_smartphone_data(self,
                                                              challenge, registration_url,
                                                              pem_privkey, options)
                     res = fb_gateway.submit_message(self.get_tokeninfo("firebase_token"), smartphone_data)


Test patch:
diff --git a/tests/base.py b/tests/base.py
--- a/tests/base.py
+++ b/tests/base.py
@@ -3,6 +3,7 @@
 import unittest
 import mock
 from sqlalchemy.orm.session import close_all_sessions
+from flask.ctx import _AppCtxGlobals
 
 from privacyidea.app import create_app
 from privacyidea.config import TestingConfig
@@ -27,6 +28,9 @@ class FakeFlaskG(object):
     request_headers = None
     serial = None
 
+    def get(self, name, default=None):
+        return self.__dict__.get(name, default)
+
 
 class FakeAudit(Audit):
 
@@ -57,7 +61,7 @@ class MyTestCase(unittest.TestCase):
                         "399871",
                         "520489"]
 
-    
+
     @classmethod
     def setUpClass(cls):
         cls.app = create_app('testing', "")
diff --git a/tests/test_api_validate.py b/tests/test_api_validate.py
--- a/tests/test_api_validate.py
+++ b/tests/test_api_validate.py
@@ -2,8 +2,8 @@
 import logging
 from testfixtures import log_capture
 from cryptography.hazmat.backends import default_backend
-from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives import serialization, hashes
+from cryptography.hazmat.primitives.asymmetric import rsa, padding
 from privacyidea.lib.utils import to_unicode
 from urllib.parse import urlencode, quote
 import json
@@ -38,6 +38,7 @@
 from privacyidea.lib import _
 from passlib.hash import argon2
 from privacyidea.lib.smsprovider.SMSProvider import set_smsgateway
+from base64 import b32encode
 
 from testfixtures import Replace, test_datetime
 import datetime
@@ -4015,6 +4016,122 @@ def test_05_preferred_client_mode_no_accepted_values(self):
         remove_token("CR2B")
 
 
+class PushChallengeTags(MyApiTestCase):
+    serial = "hotp1"
+
+    """
+    for test 3
+    """
+
+    server_private_key = rsa.generate_private_key(public_exponent=65537,
+                                                  key_size=4096,
+                                                  backend=default_backend())
+    server_private_key_pem = to_unicode(server_private_key.private_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PrivateFormat.TraditionalOpenSSL,
+        encryption_algorithm=serialization.NoEncryption()))
+    server_public_key_pem = to_unicode(server_private_key.public_key().public_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PublicFormat.SubjectPublicKeyInfo))
+
+    # We now allow white spaces in the firebase config name
+    firebase_config_name = "my firebase config"
+
+    smartphone_private_key = rsa.generate_private_key(public_exponent=65537,
+                                                      key_size=4096,
+                                                      backend=default_backend())
+    smartphone_public_key = smartphone_private_key.public_key()
+    smartphone_public_key_pem = to_unicode(smartphone_public_key.public_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PublicFormat.SubjectPublicKeyInfo))
+    # The smartphone sends the public key in URLsafe and without the ----BEGIN header
+    smartphone_public_key_pem_urlsafe = strip_key(smartphone_public_key_pem).replace("+", "-").replace("/", "_")
+    serial_push = "PIPU001"
+    user = "selfservice"
+
+    def setUp(self):
+        self.setUp_user_realms()
+
+    def test_01_push_challenge_tags(self):
+        # Test the challenge tags of a push token
+        pin = "otppin"
+        REGISTRATION_URL = "http://test/ttype/push"
+        TTL = "10"
+
+        # set policy
+        from privacyidea.lib.tokens.pushtoken import POLL_ONLY
+        set_policy("push2", scope=SCOPE.ENROLL,
+                   action="{0!s}={1!s},{2!s}={3!s},{4!s}={5!s}".format(
+                       PUSH_ACTION.FIREBASE_CONFIG, POLL_ONLY,
+                       PUSH_ACTION.REGISTRATION_URL, REGISTRATION_URL,
+                       PUSH_ACTION.TTL, TTL))
+
+        set_policy("push1", scope=SCOPE.AUTH,
+                   action=PUSH_ACTION.MOBILE_TEXT + "=Login von UserAgent: {ua_string} via {client_ip}/{tokentype}.")
+
+        # create push token for user with PIN
+        # 1st step
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={"type": "push",
+                                                 "pin": pin,
+                                                 "user": self.user,
+                                                 "realm": self.realm1,
+                                                 "serial": self.serial_push,
+                                                 "genkey": 1},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            detail = res.json.get("detail")
+            serial = detail.get("serial")
+            enrollment_credential = detail.get("enrollment_credential")
+
+        # 2nd step: as performed by the smartphone
+        with self.app.test_request_context('/ttype/push',
+                                           method='POST',
+                                           data={"enrollment_credential": enrollment_credential,
+                                                 "serial": serial,
+                                                 "pubkey": self.smartphone_public_key_pem_urlsafe,
+                                                 "fbtoken": "firebaseT"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            detail = res.json.get("detail")
+            serial = detail.get("serial")
+            enrollment_credential = detail.get("enrollment_credential")
+
+        # Run authentication with push token
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": self.user,
+                                                 "pass": pin}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            detail = res.json.get("detail")
+            self.assertEqual("Please confirm the authentication on your mobile device!", detail.get("message"))
+
+        # We do poll only, so we need to poll
+        ts = datetime.datetime.utcnow().isoformat()
+        sign_string = "{serial}|{timestamp}".format(serial=serial, timestamp=ts)
+        sig = self.smartphone_private_key.sign(sign_string.encode('utf8'),
+                                               padding.PKCS1v15(),
+                                               hashes.SHA256())
+        # now check that we receive the challenge when polling
+        with self.app.test_request_context('/ttype/push',
+                                           method='GET',
+                                           data={"serial": serial,
+                                                 "timestamp": ts,
+                                                 "signature": b32encode(sig)}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            value = result.get("value")
+            self.assertEqual("Login von UserAgent:  via None/push.", value[0].get("question"))
+
+        remove_token(self.serial_push)
+        delete_policy("push2")
+        delete_policy("push1")
+
+
 class AChallengeResponse(MyApiTestCase):
 
     serial = "hotp1"