Repository: privacyidea/privacyidea

Issue:
Add qr code to enroll privacyIDEA Authenticator
In the HOTP, TOTP, PUSH enrollment page we could add a QR code, that links to the Play Store and App Store to install the privacyIDEA Authenticator.

How it would be added is to be discussed.
This should be configurable.


Discussion:
The link could be customizable to custom apps.

We could also have a QR code to a link of a website, that checks, if this is and iOS or Android device and then sends the phone to the corresponding store.

Todo: Do some concept
As it is an enrollment page in the UI, we can add webui policies.

* scope: webui
* action:
   * app_url_android: default: https://play.google.com/store/apps/details?id=it.netknights.piauthenticator
   * app_url_ios: default: https://apps.apple.com/de/app/privacyidea-authenticator/id1445401301

If the policy is set and a URL is configured we can add the QR Code of the URL in the rollout page.
Should we then display both QR-Codes (along with the Token)? What if people want to use a redirect-page based on the OS (only one URL/QR-Code)? Maybe we (privacyIDEA) can provide a redirect service for our apps.
Interesting aspect with the redirect.
The question is, if the smartphone can actually the privacyIDEA server. They could be in a different network. -> The smartphone would need to reach a public available redirect service. 

So the simple way could be: provide two QR codes.

The elegant way would be: provide one QR code -> redirect -> go to real app stores.

But after all the elegant way could be different in any situation.
So if s.o. wants to change the default URLs, he could also use the his own redirect service.
Could this mean, that we only need to configure *one* URL, the redirect service?
And if this is not configured, we simply display the two default QR codes. (I would like this, it sounds straightforward and works out of the box. "If you know what you are doing, you can configure your own URL".)


Actually i meant our own privacyIDEA webserver for the (default) redirect service :-) The privacyIDEA server should not do this.
Of course customers might want to add links to their version of the (Android/IOS) App.

Ah, you mean privacyidea.org/redirect-to-app-play-store.html ?

Cool. This would mean so much traffic on our webpage and we would know, how many smartphones are using our app. and where.

Interesting approach - but from a data protection /privacy perspective I would rather not do this. Let's try to avoid running any central service that is need for privacyidea instances to work. Just my personal feeling.
I do feel the same :-) And yes, statistics would be great ;-)
Anyway, this would be an additional option. We could provide an apache configuration which does the redirect based on the OS.

Resolving patch:
diff --git a/privacyidea/api/lib/postpolicy.py b/privacyidea/api/lib/postpolicy.py
--- a/privacyidea/api/lib/postpolicy.py
+++ b/privacyidea/api/lib/postpolicy.py
@@ -1,5 +1,7 @@
 # -*- coding: utf-8 -*-
 #
+#  2020-02-16 Cornelius Kölbel <cornelius.koelbel@netknights.it>
+#             Add QR codes for Authenticator Apps
 #  2016-02-07 Cornelius Kölbel <cornelius.koelbel@netknights.it>
 #             Add tokenwizard
 #  2015-10-25 Cornelius Kölbel <cornelius.koelbel@netknights.it>
@@ -45,6 +47,7 @@
 from privacyidea.lib.error import PolicyError
 from flask import g, current_app, make_response
 from privacyidea.lib.policy import SCOPE, ACTION, AUTOASSIGNVALUE
+from privacyidea.lib.policy import DEFAULT_ANDROID_APP_URL, DEFAULT_IOS_APP_URL
 from privacyidea.lib.policy import Match
 from privacyidea.lib.token import get_tokens, assign_token, get_realms_of_token, get_one_token
 from privacyidea.lib.machine import get_hostname, get_auth_items
@@ -59,6 +62,7 @@
 from privacyidea.lib.user import User
 from privacyidea.lib.realm import get_default_realm
 from privacyidea.lib.subscriptions import subscription_status
+from privacyidea.lib.utils import create_img
 
 log = logging.getLogger(__name__)
 
@@ -520,6 +524,16 @@ def get_webui_settings(request, response):
                                             realm=realm).action_values(unique=True)
         show_seed = Match.realm(g, scope=SCOPE.WEBUI, action=ACTION.SHOW_SEED,
                                 realm=realm).any()
+        qr_ios_authenticator = Match.realm(g, scope=SCOPE.WEBUI, action=ACTION.SHOW_IOS_AUTHENTICATOR,
+                                           realm=realm).any()
+        qr_android_authenticator = Match.realm(g, scope=SCOPE.WEBUI, action=ACTION.SHOW_ANDROID_AUTHENTICATOR,
+                                               realm=realm).any()
+        qr_custom_authenticator_url = Match.realm(g, scope=SCOPE.WEBUI, action=ACTION.SHOW_CUSTOM_AUTHENTICATOR,
+                                                  realm=realm).action_values(unique=True)
+
+        qr_image_android = create_img(DEFAULT_ANDROID_APP_URL) if qr_android_authenticator else None
+        qr_image_ios = create_img(DEFAULT_IOS_APP_URL) if qr_ios_authenticator else None
+        qr_image_custom = create_img(list(qr_custom_authenticator_url)[0]) if qr_custom_authenticator_url else None
         token_page_size = DEFAULT_PAGE_SIZE
         user_page_size = DEFAULT_PAGE_SIZE
         default_tokentype = DEFAULT_TOKENTYPE
@@ -559,6 +573,9 @@ def get_webui_settings(request, response):
         content["result"]["value"]["hide_buttons"] = hide_buttons
         content["result"]["value"]["show_seed"] = show_seed
         content["result"]["value"]["subscription_status"] = subscription_status()
+        content["result"]["value"]["qr_image_android"] = qr_image_android
+        content["result"]["value"]["qr_image_ios"] = qr_image_ios
+        content["result"]["value"]["qr_image_custom"] = qr_image_custom
         response.set_data(json.dumps(content))
     return response
 
diff --git a/privacyidea/lib/policy.py b/privacyidea/lib/policy.py
--- a/privacyidea/lib/policy.py
+++ b/privacyidea/lib/policy.py
@@ -193,6 +193,8 @@
 optional = True
 required = False
 
+DEFAULT_ANDROID_APP_URL = "https://play.google.com/store/apps/details?id=it.netknights.piauthenticator"
+DEFAULT_IOS_APP_URL = "https://apps.apple.com/us/app/privacyidea-authenticator/id1445401301"
 
 class SCOPE(object):
     __doc__ = """This is the list of the allowed scopes that can be used in
@@ -348,6 +350,9 @@ class ACTION(object):
     STATISTICSDELETE = "statistics_delete"
     LOGIN_TEXT = "login_text"
     DIALOG_NO_TOKEN = "dialog_no_token"
+    SHOW_ANDROID_AUTHENTICATOR = "show_android_privacyidea_authenticator"
+    SHOW_IOS_AUTHENTICATOR = "show_ios_privacyidea_authenticator"
+    SHOW_CUSTOM_AUTHENTICATOR = "show_custom_authenticator"
 
 
 class GROUP(object):
@@ -2140,6 +2145,26 @@ def get_static_policy_definitions(scope=None):
                 'type': 'bool',
                 'desc': _("If this is checked, the seed "
                           "will be displayed as text during enrollment.")
+            },
+            ACTION.SHOW_ANDROID_AUTHENTICATOR: {
+                'type': 'bool',
+                'desc': _("If this is checked, the enrollment page for HOTP, "
+                          "TOTP and Push tokens will contain a QR code that leads "
+                          "to the privacyIDEA Authenticator in the Google Play Store."),
+                'group': 'QR Codes'
+            },
+            ACTION.SHOW_IOS_AUTHENTICATOR: {
+                'type': 'bool',
+                'desc': _("If this is checked, the enrollment page for HOTP, "
+                          "TOTP and Push tokens will contain a QR code that leads "
+                          "to the privacyIDEA Authenticator in the iOS App Store."),
+                'group': 'QR Codes'
+            },
+            ACTION.SHOW_CUSTOM_AUTHENTICATOR: {
+                'type': 'str',
+                'desc': _("This action adds a QR code in the enrollment page for "
+                          "HOTP, TOTP and Push tokens, that lead to this given URL."),
+                'group': 'QR Codes'
             }
         }
 


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
@@ -59,6 +59,7 @@
 from datetime import datetime, timedelta
 from dateutil.tz import tzlocal
 from privacyidea.lib.tokenclass import DATE_FORMAT
+from privacyidea.lib.utils import create_img
 
 
 HOSTSFILE = "tests/testdata/hosts"
@@ -2380,6 +2381,57 @@ def test_08_get_webui_settings(self):
             ACTION.DIALOG_NO_TOKEN), True)
         delete_policy("pol_dialog")
 
+        # Set a policy for the QR codes
+        set_policy(name="pol_qr1", scope=SCOPE.WEBUI, action=ACTION.SHOW_ANDROID_AUTHENTICATOR)
+        set_policy(name="pol_qr2", scope=SCOPE.WEBUI, action=ACTION.SHOW_IOS_AUTHENTICATOR)
+        set_policy(name="pol_qr3", scope=SCOPE.WEBUI,
+                   action="{0!s}=http://privacyidea.org".format(ACTION.SHOW_CUSTOM_AUTHENTICATOR))
+
+        custom_url = create_img("http://privacyidea.org")
+        g.policy_object = PolicyClass()
+        new_response = get_webui_settings(req, resp)
+        jresult = new_response.json
+        self.assertEqual("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAcIAAAHCAQAAAABUY/ToAAADkElEQVR4nO2cTW"
+                         "7bMBBG35QEsqQBH6BHoW7WM/UG0lFygALiMgCF6YI/ouJ0k9iI7c4sHEHRAyXgw/CbISVRPhfLj0+CYKSRRh"
+                         "pppJFGGnl/pNTwyMQmIieQKYkAW/mRKbWrpm++WyPvi/TlT5wB0hklncopXU4uC2FVwCkklwGQr45p5HOSqe"
+                         "cXnBJfa0aipyAARMRfb0wjn5OUiU10BoiaIa7AcgKZbjamkY9N+stTLgs4IJ2VRUCX6apjGvlcZNNQUCABhDc"
+                         "h/j6jpE0Al4nrOQvA2JB8rOc08ubkIlKqMUgvRSky4ZS47tPYVsqya41p5JOQJQ+N+SVkgE10OTl0OTnaJcdl"
+                         "kcd6TiNvR6KqqkTVknOIq1MIqqpruyb2I0K9WOfHek4jb0cWDRW9xHqsTUi1LitlGkHLuXKNacjIFoOGwKnOY"
+                         "RSSziF3DWV0xqlpyMhjNA05VdWeZHCqMzUFVYU1NZmGjPyIXH6+iUzJwyIe+bVuIlNx1yBTyOg8FGzffLdG3h"
+                         "U5eGqdiwFyvf6quamabdVmti0PGTlG0RCtF9SEBLsfatdlwPyQkR+TqusmtTWUXmr5VeSTfE1By4lDlf+Iz2n"
+                         "kLcjSYxRC9roICGzCIiBx3vzYVkx+aDk+2nMaeTuyF2JQG0KaqWVar+2LKSpzntVlRr6P5odozZ+hK+S0SYqi"
+                         "q+KRTENGHmJoCA2rHpqp9ijUFLSbbdOQkcdotXpuJ4pytHasazKq013rRZqGjBxin8sOjie32p62XhabKTING"
+                         "XmMqqHQbM88dIp2cfUsZf0hIy+iuaDcjtZDP3EOrYtdlvZXZ3s/jHwXY20Pg3/e9w+paltunTFPbeT72LPPXo"
+                         "gNTrrLp21Ts7nMyPdRJbG2oqu6IFd+2gzW7JFpyMh/kkFV5GeG+OrZd57VffpQ9oOUo+lKYxr5HOS4bl9MdO9"
+                         "O1yV7p+NKq9VlRl6EHmPcjh+aH9JWje31m2nIyCO5f/eDuG7CcgLVV49MyVN3NCb/Afkdd2vkfZHjPsa+3Fqi"
+                         "Lpr11lCf86w/ZORHZPvuh0zJsxdnOidPeS1o2V9xvdKYRj4nOWx+ncsWxjeBJFJ7jD1L3cXdGnkP5MV3P+Lri"
+                         "wph87pMDiH8qR+tius5Q/JZvzqmkc9FXvqh/V+hr3r04qwT5oeM7NH6Q8DwcgfdXYe9om9raOapjRxD7BvnRh"
+                         "pppJFGGmnkf07+BXIeTK/jr1P8AAAAAElFTkSuQmCC",
+                         jresult.get("result").get("value").get("qr_image_android"))
+        self.assertEqual("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAcIAAAHCAQAAAABUY/ToAAADr0lEQVR4nO2cXYr"
+                         "jOBCAv1oJ8mhDHyBHkW827JH6BtZRcoCA9BiwqXmQZCvpaRaGhCS9VQ8hsv1hCYpS/cmi/J3Ef/4SBCONNNJI"
+                         "I4000sjXI6WKL/+I4yrAKjJlEZFxFcjtqenJszXytUhUVZWgqqrJqc6Dqqou6Dws6DyoAq7e3R+e32udRj6ez"
+                         "Jt9yR7i6FQmnAKrqOpFABARf793GvkzSH8zFoazKNkvhE+/SDh55M7vNPJnkbc6BNkjQUHj6FBYvMYJJHze65"
+                         "1G/iyy6dCgQAYYEgKrp1iksqG5BTL0Ccn3WqeRDyejiIiMQDh5IB+UcDqoTNkjvxLIxFrCsufP1siXIosd6ux"
+                         "LHBGFhfITj5cS5VdTdY93GvmzyC62JyQAnOqMa9f2oB+AwWJ7I2+k+UN5RONx8QqrENIHWq6NZ0+YL0JQiqOk"
+                         "z5utka9IFjtUjVHLMcLQcoyqC4REGVKyjWaHjOylqkSi5qRDSUcv7X5LW7ddrRGmQ0Y2ubJDQNBmbroyR1Mp1"
+                         "fKI6ZCRnTQ75LQVyNrm1V0DijtN2d9Mh4zspGpESSVWS7OXW80fMvK/ZfeHum1M03Y3OS03yk8yHTLyVoortG"
+                         "lOMzzbMG27GtRdzfwhI6+kWpXm51RjNGhvfRi2a8niMiNv5coOtW0spJanBjp/SGfzh4z8Il1Yz7B51+1fNVB"
+                         "b8tHskJHfkPrvURWyCCGtonMWgeEikA8qE7Wfeo/f3nOdRj6C7Pup99TQ3BygPUIrTlFQyw8Z+Q2Zpdgc4rhl"
+                         "ik4e4gg1gZ2AKAezQ0ZeS+0fisfFS9BVlDwKIa20aOzsS+tQHM8QPj+eOVsjX5HsfOrWNXQV5ZdhqqmhvbHI9"
+                         "jIjN+l0aKtrtLhsL7I2vaplfNMhI3fZemEXXxrRQFur2XD2xAk0CghDohwaet5sjXxFcuv92Ctie321f6SW7J"
+                         "Plh4z8IykyOiWKp7Z45EM9NT3jVCagHDybgDp8x3Ua+QiyO9ehABIFYDgL4eQXYBWN4hbCDNId7nivdRr5OLL"
+                         "oUDsz5hbALWUYjwsSdPUS0geQP/oN7s3WaeTDydCaiPZorBY88qF8uKGki6J9s8HIL9LVXENqxbCqUnsTkdMb"
+                         "wnxqI78ji75E8YgcLyLTsJR/5W7nYr/CbI18SbJ8Aq31ThPFt+7F5NrBs3u/08i3Jr98B61uba71D8HWGuusf"
+                         "8jIP0nrYwTYDnfsp4S2rtjulJDVy4zsRewb50YaaaSRRhpp5P+c/A3Ni1KFc5UNygAAAABJRU5ErkJggg==",
+                         jresult.get("result").get("value").get("qr_image_ios"))
+        qr_image_custom = jresult.get("result").get("value").get("qr_image_custom")
+        self.assertEqual(len(custom_url), len(qr_image_custom))
+        self.assertEqual(custom_url, qr_image_custom)
+        delete_policy("pol_qr1")
+        delete_policy("pol_qr2")
+        delete_policy("pol_qr3")
+
     def test_16_init_token_defaults(self):
         g.logged_in_user = {"username": "cornelius",
                             "realm": "",