Andrew Bogott has submitted this change and it was merged.

Change subject: Support totp auth for horizon
......................................................................


Support totp auth for horizon

Bug: T105690
Change-Id: I3ad1a48cda39f5878afcf5287a652d5a3f1a2b99
---
A modules/openstack/files/liberty/horizon/forms.py
A modules/openstack/files/liberty/horizon/wmtotp.py
A modules/openstack/files/liberty/keystoneclient/__init__.py
A modules/openstack/files/liberty/keystoneclient/wmtotp.py
M modules/openstack/manifests/horizon/service.pp
M modules/openstack/templates/liberty/horizon/local_settings.py.erb
6 files changed, 381 insertions(+), 0 deletions(-)

Approvals:
  Andrew Bogott: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/modules/openstack/files/liberty/horizon/forms.py 
b/modules/openstack/files/liberty/horizon/forms.py
new file mode 100644
index 0000000..0aeac58
--- /dev/null
+++ b/modules/openstack/files/liberty/horizon/forms.py
@@ -0,0 +1,146 @@
+# 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 collections
+import logging
+
+import django
+from django.conf import settings
+from django.contrib.auth import authenticate  # noqa
+from django.contrib.auth import forms as django_auth_forms
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+from django.views.decorators.debug import sensitive_variables  # noqa
+
+from openstack_auth import exceptions
+from openstack_auth import utils
+
+
+LOG = logging.getLogger(__name__)
+
+
+class Login(django_auth_forms.AuthenticationForm):
+    """Form used for logging in a user.
+
+    Handles authentication with Keystone by providing the domain name, username
+    and password. A scoped token is fetched after successful authentication.
+
+    A domain name is required if authenticating with Keystone V3 running
+    multi-domain configuration.
+
+    If the user authenticated has a default project set, the token will be
+    automatically scoped to their default project.
+
+    If the user authenticated has no default project set, the authentication
+    backend will try to scope to the projects returned from the user's assigned
+    projects. The first successful project scoped will be returned.
+
+    Inherits from the base ``django.contrib.auth.forms.AuthenticationForm``
+    class for added security features.
+    """
+    region = forms.ChoiceField(label=_("Region"), required=False)
+    username = forms.CharField(
+        label=_("User Name"),
+        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', 'totptoken', 'region']
+        if getattr(settings,
+                   'OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT',
+                   False):
+            self.fields['domain'] = forms.CharField(
+                label=_("Domain"),
+                required=True,
+                widget=forms.TextInput(attrs={"autofocus": "autofocus"}))
+            self.fields['username'].widget = forms.widgets.TextInput()
+            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]
+            self.fields['region'].widget = forms.widgets.HiddenInput()
+        elif len(self.fields['region'].choices) > 1:
+            self.fields['region'].initial = self.request.COOKIES.get(
+                'login_region')
+
+        # if websso is enabled and keystone version supported
+        # prepend the websso_choices select input to the form
+        if utils.is_websso_enabled():
+            initial = getattr(settings, 'WEBSSO_INITIAL_CHOICE', 'credentials')
+            self.fields['auth_type'] = forms.ChoiceField(
+                label=_("Authenticate using"),
+                choices=getattr(settings, 'WEBSSO_CHOICES', ()),
+                required=False,
+                initial=initial)
+            # add auth_type to the top of the list
+            fields_ordering.insert(0, 'auth_type')
+
+        # websso is enabled, but keystone version is not supported
+        elif getattr(settings, 'WEBSSO_ENABLED', False):
+            msg = ("Websso is enabled but horizon is not configured to work " +
+                   "with keystone version 3 or above.")
+            LOG.warning(msg)
+        # Starting from 1.7 Django uses OrderedDict for fields and keyOrder
+        # no longer works for it
+        if django.VERSION >= (1, 7):
+            self.fields = collections.OrderedDict(
+                (key, self.fields[key]) for key in fields_ordering)
+        else:
+            self.fields.keyOrder = fields_ordering
+
+    @staticmethod
+    def get_region_choices():
+        default_region = (settings.OPENSTACK_KEYSTONE_URL, "Default Region")
+        regions = getattr(settings, 'AVAILABLE_REGIONS', [])
+        if not regions:
+            regions = [default_region]
+        return regions
+
+    @sensitive_variables()
+    def clean(self):
+        default_domain = getattr(settings,
+                                 'OPENSTACK_KEYSTONE_DEFAULT_DOMAIN',
+                                 '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 and token):
+            # Don't authenticate, just let the other validators handle it.
+            return self.cleaned_data
+
+        try:
+            self.user_cache = authenticate(request=self.request,
+                                           username=username,
+                                           password=password,
+                                           totp=token,
+                                           user_domain_name=domain,
+                                           auth_url=region)
+            msg = 'Login successful for user "%(username)s".' % \
+                {'username': username}
+            LOG.info(msg)
+        except exceptions.KeystoneAuthException as exc:
+            msg = 'Login failed for user "%(username)s".' % \
+                {'username': username}
+            LOG.warning(msg)
+            raise forms.ValidationError(exc)
+        if hasattr(self, 'check_for_test_cookie'):  # Dropped in django 1.7
+            self.check_for_test_cookie()
+        return self.cleaned_data
diff --git a/modules/openstack/files/liberty/horizon/wmtotp.py 
b/modules/openstack/files/liberty/horizon/wmtotp.py
new file mode 100644
index 0000000..75b78d1
--- /dev/null
+++ b/modules/openstack/files/liberty/horizon/wmtotp.py
@@ -0,0 +1,47 @@
+# 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 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)
diff --git a/modules/openstack/files/liberty/keystoneclient/__init__.py 
b/modules/openstack/files/liberty/keystoneclient/__init__.py
new file mode 100644
index 0000000..c9ecd12
--- /dev/null
+++ b/modules/openstack/files/liberty/keystoneclient/__init__.py
@@ -0,0 +1,34 @@
+# 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.
+
+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',
+           'AuthConstructor',
+           'AuthMethod',
+           'BaseAuth',
+
+           'FederatedBaseAuth',
+
+           'Password',
+           'PasswordMethod',
+
+           'Mwtotp',
+           'MwtotpMethod',
+
+           'Token',
+           'TokenMethod']
diff --git a/modules/openstack/files/liberty/keystoneclient/wmtotp.py 
b/modules/openstack/files/liberty/keystoneclient/wmtotp.py
new file mode 100644
index 0000000..cc52f93
--- /dev/null
+++ b/modules/openstack/files/liberty/keystoneclient/wmtotp.py
@@ -0,0 +1,112 @@
+#
+#  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)
diff --git a/modules/openstack/manifests/horizon/service.pp 
b/modules/openstack/manifests/horizon/service.pp
index 8f6d4ac..0e551d0 100644
--- a/modules/openstack/manifests/horizon/service.pp
+++ b/modules/openstack/manifests/horizon/service.pp
@@ -12,6 +12,12 @@
         ensure  => present,
         require => Class['openstack::repo',  '::apache::mod::wsgi'];
     }
+    package { 'python-keystoneclient':
+        ensure  => present,
+    }
+    package { 'python-openstack-auth':
+        ensure  => present,
+    }
 
     include ::apache
     include ::apache::mod::ssl
@@ -85,6 +91,40 @@
         mode    => '0444',
     }
 
+    # Homemade totp plugin for keystoneclient
+    file { 
'/usr/lib/python2.7/dist-packages/keystoneclient/auth/identity/v3/wmtotp.py':
+        source  => 
"puppet:///modules/openstack/${openstack_version}/keystoneclient/wmtotp.py",
+        owner   => 'root',
+        group   => 'root',
+        require => Package['python-keystoneclient'],
+        mode    => '0644',
+    }
+    file { 
'/usr/lib/python2.7/dist-packages/keystoneclient/auth/identity/v3/__init__.py':
+        source  => 
"puppet:///modules/openstack/${openstack_version}/keystoneclient/__init__.py",
+        owner   => 'root',
+        group   => 'root',
+        require => Package['python-keystoneclient'],
+        mode    => '0644',
+    }
+
+    # Homemade totp plugin for openstack_auth
+    file { '/usr/lib/python2.7/dist-packages/openstack_auth/plugin/wmtotp.py':
+        source  => 
"puppet:///modules/openstack/${openstack_version}/horizon/wmtotp.py",
+        owner   => 'root',
+        group   => 'root',
+        require => Package['python-openstack-auth'],
+        mode    => '0644',
+    }
+
+    # Replace the standard horizon login form to support 2fa
+    file { '/usr/lib/python2.7/dist-packages/openstack_auth/forms.py':
+        source  => 
"puppet:///modules/openstack/${openstack_version}/horizon/forms.py",
+        owner   => 'root',
+        group   => 'root',
+        require => Package['python-openstack-auth'],
+        mode    => '0644',
+    }
+
     apache::site { $webserver_hostname:
         content => 
template("openstack/${$openstack_version}/horizon/${webserver_hostname}.erb"),
         require => File['/etc/openstack-dashboard/local_settings.py'],
diff --git a/modules/openstack/templates/liberty/horizon/local_settings.py.erb 
b/modules/openstack/templates/liberty/horizon/local_settings.py.erb
index cbe3385..6e8b0b3 100644
--- a/modules/openstack/templates/liberty/horizon/local_settings.py.erb
+++ b/modules/openstack/templates/liberty/horizon/local_settings.py.erb
@@ -18,6 +18,8 @@
 # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
 ALLOWED_HOSTS = ['horizon.wikimedia.org', ]
 
+AUTHENTICATION_PLUGINS = ['openstack_auth.plugin.wmtotp.WmtotpPlugin', 
'openstack_auth.plugin.token.TokenPlugin']
+
 # Set SSL proxy settings:
 # For Django 1.4+ pass this header from the proxy after terminating the SSL,
 # and don't forget to strip it from the client's request.

-- 
To view, visit https://gerrit.wikimedia.org/r/274173
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I3ad1a48cda39f5878afcf5287a652d5a3f1a2b99
Gerrit-PatchSet: 15
Gerrit-Project: operations/puppet
Gerrit-Branch: production
Gerrit-Owner: Andrew Bogott <[email protected]>
Gerrit-Reviewer: Alex Monk <[email protected]>
Gerrit-Reviewer: Andrew Bogott <[email protected]>
Gerrit-Reviewer: CSteipp <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to