Andrew Bogott has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/274173

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

Support totp auth for horizon

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/wmtotp.py
M modules/openstack/manifests/horizon/service.pp
M modules/openstack/templates/liberty/horizon/local_settings.py.erb
5 files changed, 345 insertions(+), 0 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/operations/puppet 
refs/changes/73/274173/1

diff --git a/modules/openstack/files/liberty/horizon/forms.py 
b/modules/openstack/files/liberty/horizon/forms.py
new file mode 100644
index 0000000..b6b318e
--- /dev/null
+++ b/modules/openstack/files/liberty/horizon/forms.py
@@ -0,0 +1,145 @@
+# 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..cb607fc
--- /dev/null
+++ b/modules/openstack/files/liberty/horizon/wmtotp.py
@@ -0,0 +1,48 @@
+# 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, and 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:
+            # Throw an exception
+            pass
+
diff --git a/modules/openstack/files/liberty/keystoneclient/wmtotp.py 
b/modules/openstack/files/liberty/keystoneclient/wmtotp.py
new file mode 100644
index 0000000..0d499ba
--- /dev/null
+++ b/modules/openstack/files/liberty/keystoneclient/wmtotp.py
@@ -0,0 +1,109 @@
+# Copyright 2016 Wikimedia Foundation
+#
+#  (this is a custom hack local to the Wikimedia Labs deployment)
+#
+# 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 oslo_log import log
+from oslo_config import cfg
+
+from keystone import auth
+from keystone.auth import plugins as auth_plugins
+from keystone.common import dependency
+from keystone import exception
+from keystone.i18n import _
+
+import oath
+import base64
+import mysql.connector
+
+METHOD_NAME = 'mwtotp'
+
+LOG = log.getLogger(__name__)
+CONF = cfg.CONF
+
+oathoptions = [
+    cfg.StrOpt('dbuser',
+               default='wiki_user',
+               help='Database user for retrieving OATH secret.'),
+    cfg.StrOpt('dbpass',
+               default='12345',
+               help='Database password for retrieving OATH secret.'),
+    cfg.StrOpt('dbhost',
+               default='localhost',
+               help='Database host for retrieving OATH secret.'),
+    cfg.StrOpt('dbname',
+               default='labswiki',
+               help='Database name for retrieving OATH secret.'),
+]
+
+for option in oathoptins:
+    conf.register_opt(option, group='oath')
+
+
[email protected]('identity_api')
+class Mwtotp(auth.AuthMethodHandler):
+
+    method = METHOD_NAME
+
+    def authenticate(self, context, auth_payload, auth_context):
+        """Try to authenticate against the identity backend."""
+        user_info = auth_plugins.UserAuthInfo.create(auth_payload, self.method)
+
+        # FIXME(gyee): identity.authenticate() can use some refactoring since
+        # all we care is password matches
+        try:
+            self.identity_api.authenticate(
+                context,
+                user_id=user_info.user_id,
+                password=user_info.password)
+        except AssertionError:
+            # authentication failed because of invalid username or password
+            msg = _('Invalid username or password')
+            raise exception.Unauthorized(msg)
+
+
+        # Password auth succeeded, check two-factor
+        # LOG.debug("OATH: Doing 2FA for user_info " + ( "%s(%r)" % 
(user_info.__class__, user_info.__dict__) ) )
+        # LOG.debug("OATH: Doing 2FA for auth_payload " + ( "%s(%r)" % 
(auth_payload.__class__, auth_payload) ) )
+        cnx = mysql.connector.connect(
+            user=CONF.oath.dbuser,
+            password=CONF.oath.dbpass,
+            database=CONF.oath.dbname,
+            host=CONF.oath.dbhost)
+        cur = cnx.cursor(buffered=True)
+        sql = ('SELECT oath.secret as secret from user '
+              'left join oathauth_users as oath on oath.id = user.user_id '
+              'where user.user_name = %s LIMIT 1')
+        cur.execute(sql, (user_info.user_ref['name'], ))
+        secret = cur.fetchone()[0]
+
+        if secret:
+            if 'mwtotp' in auth_payload['user']:
+                (p, d) = oath.accept_totp(
+                    base64.b16encode(base64.b32decode(secret)),
+                    auth_payload['user']['totp'])
+                if p:
+                    LOG.debug("OATH: 2FA passed")
+                else:
+                    LOG.debug("OATH: 2FA failed")
+                    msg = _('Invalid two-factor token')
+                    raise exception.Unauthorized(msg)
+            else:
+                LOG.debug("OATH: 2FA failed, missing totp param")
+                msg = _('Missing two-factor token')
+                raise exception.Unauthorized(msg)
+        else:
+            LOG.debug("OATH: user '%s' does not have 2FA enabled.", 
user_info.user_ref['name'])
+
+        auth_context['user_id'] = user_info.user_id
diff --git a/modules/openstack/manifests/horizon/service.pp 
b/modules/openstack/manifests/horizon/service.pp
index 8f6d4ac..b226589 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,41 @@
         mode    => '0444',
     }
 
+    file { 
'/usr/share/openstack-dashboard/openstack_dashboard/static/dashboard/img/favicon.ico':
+        source  => 'puppet:///modules/openstack/horizon/Wikimedia_labs.ico',
+        owner   => 'horizon',
+        group   => 'horizon',
+        require => Package['openstack-dashboard'],
+        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',
+    }
+
+    # 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-keystoneclient'],
+        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-keystoneclient'],
+        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 a7d6dd1..6eb4f7b 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.totp.WmotpPlugin', 
'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: newchange
Gerrit-Change-Id: I3ad1a48cda39f5878afcf5287a652d5a3f1a2b99
Gerrit-PatchSet: 1
Gerrit-Project: operations/puppet
Gerrit-Branch: production
Gerrit-Owner: Andrew Bogott <[email protected]>

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

Reply via email to