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 ‘wmtotp.py’ and it’s a copy of the ‘password.py’ 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,
username=username,
password=password,
totp=totp,
user_domain_name=user_domain_name,
unscoped=True)
else:
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 local_settings.py 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 “forms.py” that draws a single username/password dialog, with no custom field options. Time to get ugly and clobber forms.py with a patched version.
diff --git a/openstack_auth/forms.py b/openstack_auth/forms.py
index 97a8bbf..0aeac58 100644
diff --git a/openstack_auth/forms.py b/openstack_auth/forms.py
index 97a8bbf..0aeac58 100644
--- a/openstack_auth/forms.py
+++ b/openstack_auth/forms.py
@@ -54,10 +54,12 @@ class Login(django_auth_forms.AuthenticationForm):
widget=forms.TextInput(attrs={"autofocus": "autofocus"}))
password = forms.CharField(label=_("Password"),
widget=forms.PasswordInput(render_value=False))
+ 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,
'OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT',
False):
@@ -66,7 +68,8 @@ class Login(django_auth_forms.AuthenticationForm):
required=True,
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):
'Default')
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,
username=username,
password=password,
+ totp=token,
user_domain_name=domain,
That’s it for Horizon! There’s one more layer between us and Keystone: the Keystoneclient code that’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’s a modest modification of password.py:
#
# 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
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# 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',
'username',
'user_domain_id',
'user_domain_name',
'password',
'totp']
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
@classmethod
def get_options(cls):
options = super(Wmtotp, cls).get_options()
options.extend([
cfg.StrOpt('user-id', help='User ID'),
cfg.StrOpt('user-name', dest='username', help='Username',
deprecated_name='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
@classmethod
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
sys.stdin.isatty()):
try:
kwargs['totp'] = getpass.getpass('Totp token: ')
except EOFError:
pass
return super(Wmtotp, cls).load_from_argparse_arguments(namespace,
**kwargs)
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:
diff --git a/keystoneclient/auth/identity/v3/__init__.py b/keystoneclient/auth/identity/v3/__init__.py
index a08f3ec..c9ecd12 100644
--- a/keystoneclient/auth/identity/v3/__init__.py
+++ b/keystoneclient/auth/identity/v3/__init__.py
@@ -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',
'Password',
'PasswordMethod',
+ 'Mwtotp',
+ 'MwtotpMethod',
+
'Token',
'TokenMethod']
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!