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
