{"id":2356,"date":"2016-03-04T21:32:53","date_gmt":"2016-03-04T21:32:53","guid":{"rendered":"http:\/\/bogott.net\/unspecified\/?p=2356"},"modified":"2016-03-04T21:34:43","modified_gmt":"2016-03-04T21:34:43","slug":"keystone-horizon-and-multi-factor-auth-part-22-horizon","status":"publish","type":"post","link":"https:\/\/bogott.net\/unspecified\/?p=2356","title":{"rendered":"Keystone, Horizon, and multi-factor auth (part 2\/2: Horizon)"},"content":{"rendered":"<p>Now that <a href=\"https:\/\/bogott.net\/unspecified\/?p=2344\">Keystone accepts and checks auth requests<\/a> with user\/password\/otp, we have to get all three from the user and get them properly packed into Horizon&#8217;s login request.  Taking advantage of Horizon being backwards-compatibility, I upgraded our Horizon install to version Liberty before starting.<\/p>\n<p>I spent quite a while hopelessly grepping in the Horizon and Dashboard code until someone on IRC directed me to the <a href=\"https:\/\/github.com\/openstack\/django_openstack_auth\">openstack_auth<\/a> module which turns out to contain all the good bits.  Once again, the auth code uses a plugin model, so adding totp support is just a matter of dropping a new file into openstack_auth\/plugins\/.  My new file is called &#8216;wmtotp.py&#8217; and it&#8217;s a copy of the &#8216;password.py&#8217; plugin with the Keystone v2 API code ripped out and an extra parameter added on.<\/p>\n<p><code><br \/>\nimport logging<\/p>\n<p>from keystoneclient.auth.identity import v2 as v2_auth<br \/>\nfrom keystoneclient.auth.identity import v3 as v3_auth<\/p>\n<p>from openstack_auth.plugin import base<br \/>\nfrom openstack_auth import utils<\/p>\n<p>LOG = logging.getLogger(__name__)<\/p>\n<p>__all__ = ['WmtotpPlugin']<\/p>\n<p>class WmtotpPlugin(base.BasePlugin):<br \/>\n    \"\"\"Authenticate against keystone given a username, password, totp token.<br \/>\n    \"\"\"<\/p>\n<p>    def get_plugin(self, auth_url=None, username=None, password=None,<br \/>\n                   user_domain_name=None, totp=None, **kwargs):<br \/>\n        if not all((auth_url, username, password, totp)):<br \/>\n            return None<\/p>\n<p>        LOG.debug('Attempting to authenticate for %s', username)<\/p>\n<p>        if utils.get_keystone_version() >= 3:<br \/>\n            return v3_auth.Wmtotp(auth_url=auth_url,<br \/>\n                                  username=username,<br \/>\n                                  password=password,<br \/>\n                                  totp=totp,<br \/>\n                                  user_domain_name=user_domain_name,<br \/>\n                                  unscoped=True)<\/p>\n<p>        else:<br \/>\n            msg = \"Totp authentication requires the keystone v3 api.\"<br \/>\n            raise exceptions.KeystoneAuthException(msg)<br \/>\n<\/code><\/p>\n<p>I also needed to tell Horizon to use the new auth method during logins.  That&#8217;s a change to the local_settings.py config file:<\/p>\n<p><code>AUTHENTICATION_PLUGINS = ['openstack_auth.plugin.wmtotp.WmtotpPlugin', 'openstack_auth.plugin.token.TokenPlugin']<\/code><\/p>\n<p>Now we just have to get our second factor from the user, and hand it to off to WmtotpPlugin.  This is where Horizon is <em>not<\/em> extensible &#8212; there&#8217;s just &#8220;forms.py&#8221; that draws a single username\/password dialog, with no custom field options.  Time to get ugly and clobber forms.py with a patched version.<\/p>\n<p><code><br \/>\ndiff --git a\/openstack_auth\/forms.py b\/openstack_auth\/forms.py<br \/>\nindex 97a8bbf..0aeac58 100644<br \/>\ndiff --git a\/openstack_auth\/forms.py b\/openstack_auth\/forms.py<br \/>\nindex 97a8bbf..0aeac58 100644<br \/>\n--- a\/openstack_auth\/forms.py<br \/>\n+++ b\/openstack_auth\/forms.py<br \/>\n@@ -54,10 +54,12 @@ class Login(django_auth_forms.AuthenticationForm):<br \/>\n         widget=forms.TextInput(attrs={\"autofocus\": \"autofocus\"}))<br \/>\n     password = forms.CharField(label=_(\"Password\"),<br \/>\n                                widget=forms.PasswordInput(render_value=False))<br \/>\n+    totptoken = forms.CharField(label=_(\"Totp Token\"),<br \/>\n+                                widget=forms.TextInput())<\/p>\n<p>     def __init__(self, *args, **kwargs):<br \/>\n         super(Login, self).__init__(*args, **kwargs)<br \/>\n-        fields_ordering = ['username', 'password', 'region']<br \/>\n+        fields_ordering = ['username', 'password', 'totptoken', 'region']<br \/>\n         if getattr(settings,<br \/>\n                    'OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT',<br \/>\n                    False):<br \/>\n@@ -66,7 +68,8 @@ class Login(django_auth_forms.AuthenticationForm):<br \/>\n                 required=True,<br \/>\n                 widget=forms.TextInput(attrs={\"autofocus\": \"autofocus\"}))<br \/>\n             self.fields['username'].widget = forms.widgets.TextInput()<br \/>\n-            fields_ordering = ['domain', 'username', 'password', 'region']<br \/>\n+            fields_ordering = ['domain', 'username', 'password',<br \/>\n+                               'totptoken', 'region']<br \/>\n         self.fields['region'].choices = self.get_region_choices()<br \/>\n         if len(self.fields['region'].choices) == 1:<br \/>\n             self.fields['region'].initial = self.fields['region'].choices[0][0]<br \/>\n@@ -115,10 +118,11 @@ class Login(django_auth_forms.AuthenticationForm):<br \/>\n                                  'Default')<br \/>\n         username = self.cleaned_data.get('username')<br \/>\n         password = self.cleaned_data.get('password')<br \/>\n+        token = self.cleaned_data.get('totptoken')<br \/>\n         region = self.cleaned_data.get('region')<br \/>\n         domain = self.cleaned_data.get('domain', default_domain)<\/p>\n<p>-        if not (username and password):<br \/>\n+        if not (username and password and token):<br \/>\n             # Don't authenticate, just let the other validators handle it.<br \/>\n             return self.cleaned_data<\/p>\n<p>@@ -126,6 +130,7 @@ class Login(django_auth_forms.AuthenticationForm):<br \/>\n             self.user_cache = authenticate(request=self.request,<br \/>\n                                            username=username,<br \/>\n                                            password=password,<br \/>\n+                                           totp=token,<br \/>\n                                            user_domain_name=domain,<\/p>\n<p><\/code><\/p>\n<p>That&#8217;s it for Horizon!  There&#8217;s one more layer between us and Keystone:  the Keystoneclient code that&#8217;s loaded by wmtotp.py.  keystoneclient\/auth\/identity\/v3 contains classes for each auth model so, once again, we drop in a custom wmtotp.py.  And, again, it&#8217;s a modest modification of password.py:<\/p>\n<p><code><br \/>\n#<br \/>\n#  Custom addition for Wikimedia Labs to add a totp plugin to keystoneclient<br \/>\n#<br \/>\n# Licensed under the Apache License, Version 2.0 (the \"License\"); you may<br \/>\n# not use this file except in compliance with the License. You may obtain<br \/>\n# a copy of the License at<br \/>\n#<br \/>\n#      http:\/\/www.apache.org\/licenses\/LICENSE-2.0<br \/>\n#<br \/>\n# Unless required by applicable law or agreed to in writing, software<br \/>\n# distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT<br \/>\n# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the<br \/>\n# License for the specific language governing permissions and limitations<br \/>\n# under the License.<\/p>\n<p>import sys<\/p>\n<p>from oslo_config import cfg<\/p>\n<p>from keystoneclient.auth.identity.v3 import base<br \/>\nfrom keystoneclient import utils<\/p>\n<p>__all__ = ['WmtotpMethod', 'Wmtotp']<\/p>\n<p>class WmtotpMethod(base.AuthMethod):<br \/>\n    \"\"\"Construct a User\/Password\/totp based authentication method.<\/p>\n<p>    :param string password: Password for authentication.<br \/>\n    :param string totp: Totp token for authentication.<br \/>\n    :param string username: Username for authentication.<br \/>\n    :param string user_id: User ID for authentication.<br \/>\n    :param string user_domain_id: User's domain ID for authentication.<br \/>\n    :param string user_domain_name: User's domain name for authentication.<br \/>\n    \"\"\"<\/p>\n<p>    _method_parameters = ['user_id',<br \/>\n                          'username',<br \/>\n                          'user_domain_id',<br \/>\n                          'user_domain_name',<br \/>\n                          'password',<br \/>\n                          'totp']<\/p>\n<p>    def get_auth_data(self, session, auth, headers, **kwargs):<br \/>\n        user = {'password': self.password, 'totp': self.totp}<\/p>\n<p>        if self.user_id:<br \/>\n            user['id'] = self.user_id<br \/>\n        elif self.username:<br \/>\n            user['name'] = self.username<\/p>\n<p>            if self.user_domain_id:<br \/>\n                user['domain'] = {'id': self.user_domain_id}<br \/>\n            elif self.user_domain_name:<br \/>\n                user['domain'] = {'name': self.user_domain_name}<\/p>\n<p>        return 'wmtotp', {'user': user}<\/p>\n<p>class Wmtotp(base.AuthConstructor):<br \/>\n    \"\"\"A plugin for authenticating with a username, password, totp token<\/p>\n<p>    :param string auth_url: Identity service endpoint for authentication.<br \/>\n    :param string password: Password for authentication.<br \/>\n    :param string totp: totp token for authentication<br \/>\n    :param string username: Username for authentication.<br \/>\n    :param string user_id: User ID for authentication.<br \/>\n    :param string user_domain_id: User's domain ID for authentication.<br \/>\n    :param string user_domain_name: User's domain name for authentication.<br \/>\n    :param string trust_id: Trust ID for trust scoping.<br \/>\n    :param string domain_id: Domain ID for domain scoping.<br \/>\n    :param string domain_name: Domain name for domain scoping.<br \/>\n    :param string project_id: Project ID for project scoping.<br \/>\n    :param string project_name: Project name for project scoping.<br \/>\n    :param string project_domain_id: Project's domain ID for project.<br \/>\n    :param string project_domain_name: Project's domain name for project.<br \/>\n    :param bool reauthenticate: Allow fetching a new token if the current one<br \/>\n                                is going to expire. (optional) default True<br \/>\n    \"\"\"<\/p>\n<p>    _auth_method_class = WmtotpMethod<\/p>\n<p>    @classmethod<br \/>\n    def get_options(cls):<br \/>\n        options = super(Wmtotp, cls).get_options()<\/p>\n<p>        options.extend([<br \/>\n            cfg.StrOpt('user-id', help='User ID'),<br \/>\n            cfg.StrOpt('user-name', dest='username', help='Username',<br \/>\n                       deprecated_name='username'),<br \/>\n            cfg.StrOpt('user-domain-id', help=\"User's domain id\"),<br \/>\n            cfg.StrOpt('user-domain-name', help=\"User's domain name\"),<br \/>\n            cfg.StrOpt('password', secret=True, help=\"User's password\"),<br \/>\n            cfg.StrOpt('totp', secret=True, help=\"Totp token\"),<br \/>\n        ])<\/p>\n<p>        return options<\/p>\n<p>    @classmethod<br \/>\n    def load_from_argparse_arguments(cls, namespace, **kwargs):<br \/>\n        if not (kwargs.get('password') or namespace.os_password):<br \/>\n            kwargs['password'] = utils.prompt_user_password()<\/p>\n<p>        if not kwargs.get('totp') and (hasattr(sys.stdin, 'isatty') and<br \/>\n                                       sys.stdin.isatty()):<br \/>\n            try:<br \/>\n                kwargs['totp'] = getpass.getpass('Totp token: ')<br \/>\n            except EOFError:<br \/>\n                pass<\/p>\n<p>        return super(Wmtotp, cls).load_from_argparse_arguments(namespace,<br \/>\n                                                               **kwargs)<\/p>\n<p><\/code><\/p>\n<p>Now, a brief pause for bad news.  Despite everything our prior experiences have taught us, keystoneclient seems not to have been designed with extending in mind.  The __init__.py file in keystoneclient\/auth\/identity\/v3 has a hard-coded list of available auth classes, so we have to patch __init__.py:<\/p>\n<p><code><br \/>\ndiff --git a\/keystoneclient\/auth\/identity\/v3\/__init__.py b\/keystoneclient\/auth\/identity\/v3\/__init__.py<br \/>\nindex a08f3ec..c9ecd12 100644<br \/>\n--- a\/keystoneclient\/auth\/identity\/v3\/__init__.py<br \/>\n+++ b\/keystoneclient\/auth\/identity\/v3\/__init__.py<br \/>\n@@ -14,6 +14,7 @@ from keystoneclient.auth.identity.v3.base import *  # noqa<br \/>\n from keystoneclient.auth.identity.v3.federated import *  # noqa<br \/>\n from keystoneclient.auth.identity.v3.password import *  # noqa<br \/>\n from keystoneclient.auth.identity.v3.token import *  # noqa<br \/>\n+from keystoneclient.auth.identity.v3.wmtotp import *  # noqa<\/p>\n<p> __all__ = ['Auth',<br \/>\n@@ -26,5 +27,8 @@ __all__ = ['Auth',<br \/>\n            'Password',<br \/>\n            'PasswordMethod',<\/p>\n<p>+           'Mwtotp',<br \/>\n+           'MwtotpMethod',<br \/>\n+<br \/>\n            'Token',<br \/>\n            'TokenMethod']<br \/>\n<\/code><\/p>\n<p>Get with the program, keystoneclient!  Anyway, after recovering from that one disappointment it was time to make a <a href=\"https:\/\/gerrit.wikimedia.org\/r\/#\/c\/274173\">puppet patch<\/a> to drop in all of our hacks and overlays and config changes.<\/p>\n<p>And&#8230; <a href=\"https:\/\/horizon.wikimedia.org\">it works<\/a>!<\/p>\n<p><a href=\"https:\/\/bogott.net\/unspecified\/wp-content\/uploads\/2016\/03\/Screen-Shot-2016-03-04-at-3.11.34-PM.png\" rel=\"attachment wp-att-2359\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/bogott.net\/unspecified\/wp-content\/uploads\/2016\/03\/Screen-Shot-2016-03-04-at-3.11.34-PM-769x1024.png\" alt=\"Screen Shot 2016-03-04 at 3.11.34 PM\" width=\"640\" height=\"852\" class=\"alignnone size-large wp-image-2359\" srcset=\"https:\/\/bogott.net\/unspecified\/wp-content\/uploads\/2016\/03\/Screen-Shot-2016-03-04-at-3.11.34-PM-769x1024.png 769w, https:\/\/bogott.net\/unspecified\/wp-content\/uploads\/2016\/03\/Screen-Shot-2016-03-04-at-3.11.34-PM-225x300.png 225w, https:\/\/bogott.net\/unspecified\/wp-content\/uploads\/2016\/03\/Screen-Shot-2016-03-04-at-3.11.34-PM-768x1023.png 768w, https:\/\/bogott.net\/unspecified\/wp-content\/uploads\/2016\/03\/Screen-Shot-2016-03-04-at-3.11.34-PM.png 868w\" sizes=\"auto, (max-width: 640px) 100vw, 640px\" \/><\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Now that Keystone accepts and checks auth requests with user\/password\/otp, we have to get all three from the user and get them properly packed into Horizon&#8217;s login request. Taking advantage of Horizon being backwards-compatibility, I upgraded our Horizon install to &hellip; <a href=\"https:\/\/bogott.net\/unspecified\/?p=2356\">Continue reading <span class=\"meta-nav\">&rarr;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[9],"tags":[],"class_list":["post-2356","post","type-post","status-publish","format-standard","hentry","category-operations"],"_links":{"self":[{"href":"https:\/\/bogott.net\/unspecified\/index.php?rest_route=\/wp\/v2\/posts\/2356","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/bogott.net\/unspecified\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/bogott.net\/unspecified\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/bogott.net\/unspecified\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/bogott.net\/unspecified\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=2356"}],"version-history":[{"count":10,"href":"https:\/\/bogott.net\/unspecified\/index.php?rest_route=\/wp\/v2\/posts\/2356\/revisions"}],"predecessor-version":[{"id":2368,"href":"https:\/\/bogott.net\/unspecified\/index.php?rest_route=\/wp\/v2\/posts\/2356\/revisions\/2368"}],"wp:attachment":[{"href":"https:\/\/bogott.net\/unspecified\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=2356"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/bogott.net\/unspecified\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=2356"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/bogott.net\/unspecified\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=2356"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}