Keystone, Horizon, and multi-factor auth (part 2/2: Horizon)

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’s login request. Taking advantage of Horizon being backwards-compatibility, I upgraded our Horizon install to version Liberty before starting.

I spent quite a while hopelessly grepping in the Horizon and Dashboard code until someone on IRC directed me to the openstack_auth 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 ‘’ and it’s a copy of the ‘’ plugin with the Keystone v2 API code ripped out and an extra parameter added on.

import logging

from keystoneclient.auth.identity import v2 as v2_auth
from keystoneclient.auth.identity import v3 as v3_auth

from openstack_auth.plugin import base
from openstack_auth import utils

LOG = logging.getLogger(__name__)

__all__ = ['WmtotpPlugin']

class WmtotpPlugin(base.BasePlugin):
"""Authenticate against keystone given a username, password, totp token.

def get_plugin(self, auth_url=None, username=None, password=None,
user_domain_name=None, totp=None, **kwargs):
if not all((auth_url, username, password, totp)):
return None

LOG.debug('Attempting to authenticate for %s', username)

if utils.get_keystone_version() >= 3:
return v3_auth.Wmtotp(auth_url=auth_url,

msg = "Totp authentication requires the keystone v3 api."
raise exceptions.KeystoneAuthException(msg)

I also needed to tell Horizon to use the new auth method during logins. That’s a change to the config file:

AUTHENTICATION_PLUGINS = ['openstack_auth.plugin.wmtotp.WmtotpPlugin', 'openstack_auth.plugin.token.TokenPlugin']

Now we just have to get our second factor from the user, and hand it to off to WmtotpPlugin. This is where Horizon is not extensible — there’s just “” that draws a single username/password dialog, with no custom field options. Time to get ugly and clobber with a patched version.

diff --git a/openstack_auth/ b/openstack_auth/
index 97a8bbf..0aeac58 100644
diff --git a/openstack_auth/ b/openstack_auth/
index 97a8bbf..0aeac58 100644
--- a/openstack_auth/
+++ b/openstack_auth/
@@ -54,10 +54,12 @@ class Login(django_auth_forms.AuthenticationForm):
widget=forms.TextInput(attrs={"autofocus": "autofocus"}))
password = forms.CharField(label=_("Password"),
+ totptoken = forms.CharField(label=_("Totp Token"),
+ widget=forms.TextInput())

def __init__(self, *args, **kwargs):
super(Login, self).__init__(*args, **kwargs)
- fields_ordering = ['username', 'password', 'region']
+ fields_ordering = ['username', 'password', 'totptoken', 'region']
if getattr(settings,
@@ -66,7 +68,8 @@ class Login(django_auth_forms.AuthenticationForm):
widget=forms.TextInput(attrs={"autofocus": "autofocus"}))
self.fields['username'].widget = forms.widgets.TextInput()
- fields_ordering = ['domain', 'username', 'password', 'region']
+ fields_ordering = ['domain', 'username', 'password',
+ 'totptoken', 'region']
self.fields['region'].choices = self.get_region_choices()
if len(self.fields['region'].choices) == 1:
self.fields['region'].initial = self.fields['region'].choices[0][0]
@@ -115,10 +118,11 @@ class Login(django_auth_forms.AuthenticationForm):
username = self.cleaned_data.get('username')
password = self.cleaned_data.get('password')
+ token = self.cleaned_data.get('totptoken')
region = self.cleaned_data.get('region')
domain = self.cleaned_data.get('domain', default_domain)

- if not (username and password):
+ if not (username and password and token):
# Don't authenticate, just let the other validators handle it.
return self.cleaned_data

@@ -126,6 +130,7 @@ class Login(django_auth_forms.AuthenticationForm):
self.user_cache = authenticate(request=self.request,
+ totp=token,

That’s it for Horizon! There’s one more layer between us and Keystone: the Keystoneclient code that’s loaded by keystoneclient/auth/identity/v3 contains classes for each auth model so, once again, we drop in a custom And, again, it’s a modest modification of

# Custom addition for Wikimedia Labs to add a totp plugin to keystoneclient
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

import sys

from oslo_config import cfg

from keystoneclient.auth.identity.v3 import base
from keystoneclient import utils

__all__ = ['WmtotpMethod', 'Wmtotp']

class WmtotpMethod(base.AuthMethod):
"""Construct a User/Password/totp based authentication method.

:param string password: Password for authentication.
:param string totp: Totp token for authentication.
:param string username: Username for authentication.
:param string user_id: User ID for authentication.
:param string user_domain_id: User's domain ID for authentication.
:param string user_domain_name: User's domain name for authentication.

_method_parameters = ['user_id',

def get_auth_data(self, session, auth, headers, **kwargs):
user = {'password': self.password, 'totp': self.totp}

if self.user_id:
user['id'] = self.user_id
elif self.username:
user['name'] = self.username

if self.user_domain_id:
user['domain'] = {'id': self.user_domain_id}
elif self.user_domain_name:
user['domain'] = {'name': self.user_domain_name}

return 'wmtotp', {'user': user}

class Wmtotp(base.AuthConstructor):
"""A plugin for authenticating with a username, password, totp token

:param string auth_url: Identity service endpoint for authentication.
:param string password: Password for authentication.
:param string totp: totp token for authentication
:param string username: Username for authentication.
:param string user_id: User ID for authentication.
:param string user_domain_id: User's domain ID for authentication.
:param string user_domain_name: User's domain name for authentication.
:param string trust_id: Trust ID for trust scoping.
:param string domain_id: Domain ID for domain scoping.
:param string domain_name: Domain name for domain scoping.
:param string project_id: Project ID for project scoping.
:param string project_name: Project name for project scoping.
:param string project_domain_id: Project's domain ID for project.
:param string project_domain_name: Project's domain name for project.
:param bool reauthenticate: Allow fetching a new token if the current one
is going to expire. (optional) default True

_auth_method_class = WmtotpMethod

def get_options(cls):
options = super(Wmtotp, cls).get_options()

cfg.StrOpt('user-id', help='User ID'),
cfg.StrOpt('user-name', dest='username', help='Username',
cfg.StrOpt('user-domain-id', help="User's domain id"),
cfg.StrOpt('user-domain-name', help="User's domain name"),
cfg.StrOpt('password', secret=True, help="User's password"),
cfg.StrOpt('totp', secret=True, help="Totp token"),

return options

def load_from_argparse_arguments(cls, namespace, **kwargs):
if not (kwargs.get('password') or namespace.os_password):
kwargs['password'] = utils.prompt_user_password()

if not kwargs.get('totp') and (hasattr(sys.stdin, 'isatty') and
kwargs['totp'] = getpass.getpass('Totp token: ')
except EOFError:

return super(Wmtotp, cls).load_from_argparse_arguments(namespace,

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 file in keystoneclient/auth/identity/v3 has a hard-coded list of available auth classes, so we have to patch

diff --git a/keystoneclient/auth/identity/v3/ b/keystoneclient/auth/identity/v3/
index a08f3ec..c9ecd12 100644
--- a/keystoneclient/auth/identity/v3/
+++ b/keystoneclient/auth/identity/v3/
@@ -14,6 +14,7 @@ from keystoneclient.auth.identity.v3.base import * # noqa
from keystoneclient.auth.identity.v3.federated import * # noqa
from keystoneclient.auth.identity.v3.password import * # noqa
from keystoneclient.auth.identity.v3.token import * # noqa
+from keystoneclient.auth.identity.v3.wmtotp import * # noqa

__all__ = ['Auth',
@@ -26,5 +27,8 @@ __all__ = ['Auth',

+ 'Mwtotp',
+ 'MwtotpMethod',

Get with the program, keystoneclient! Anyway, after recovering from that one disappointment it was time to make a puppet patch to drop in all of our hacks and overlays and config changes.

And… it works!

Screen Shot 2016-03-04 at 3.11.34 PM

This entry was posted in Operations. Bookmark the permalink.