diff --git a/doc/source/configuration/settings.rst b/doc/source/configuration/settings.rst index 50e045d887..fdcec9db54 100644 --- a/doc/source/configuration/settings.rst +++ b/doc/source/configuration/settings.rst @@ -607,6 +607,16 @@ endpoint when looking it up in the service catalog. This overrides the ``OPENSTACK_ENDPOINT_TYPE`` parameter. If set to ``None``, ``OPENSTACK_ENDPOINT_TYPE`` is used for the identity endpoint. +OPENSTACK_KEYSTONE_MFA_TOTP_ENABLED +----------------------------------- + +.. versionadded:: 23.2.1(Bobcat) + +Default: ``False`` + +A boolean to activate TOTP support. If activated, the plugin must +be present in ``AUTHENTICATION_PLUGINS``. + OPENSTACK_HOST -------------- @@ -1276,6 +1286,18 @@ Default: A list of authentication plugins to be used. In most cases, there is no need to configure this. +If ``OPENSTACK_KEYSTONE_MFA_TOTP_ENABLED`` is true, then this should look +like this: + +.. code-block:: python + + [ + 'openstack_auth.plugin.totp.TotpPlugin', + 'openstack_auth.plugin.password.PasswordPlugin', + 'openstack_auth.plugin.token.TokenPlugin' + ] + + AUTHENTICATION_URLS ~~~~~~~~~~~~~~~~~~~ diff --git a/horizon/templates/auth/_totp_form.html b/horizon/templates/auth/_totp_form.html new file mode 100644 index 0000000000..969999cff0 --- /dev/null +++ b/horizon/templates/auth/_totp_form.html @@ -0,0 +1,59 @@ +{% load i18n %} + +{% block pre_login %} +
+ {% csrf_token %} +{% endblock %} + +
+ +
+ {% block login_header %} + + {% endblock %} +
+ +
+ {% block login_body %} + {% comment %} + These fake fields are required to prevent Chrome v34+ from autofilling form. + {% endcomment %} + {% if HORIZON_CONFIG.password_autocomplete != "on" %} + + {%endif%} +
+ {% if logout_reason %} + {% if logout_status == "success" %} +
+ {% else %} +
+ {% endif %} +

{{ logout_reason }}

+
+ {% endif %} + {% include "horizon/common/_form_fields.html" %} +
+ {% endblock %} +
+ + +
+ +{% block post_login%} +
+{% endblock %} \ No newline at end of file diff --git a/horizon/templates/auth/_totp_page.html b/horizon/templates/auth/_totp_page.html new file mode 100644 index 0000000000..37cf251ded --- /dev/null +++ b/horizon/templates/auth/_totp_page.html @@ -0,0 +1,26 @@ +{% extends 'auth/_totp_form.html' %} +{% load i18n %} + +{% block pre_login %} +
+
+
+ {{ block.super }} +{% endblock %} + +{% block login_header %} + {% include 'auth/_splash.html' %} + {{ block.super }} +{% endblock %} + +{% block login_footer %} + {{ block.super }} + {% include '_login_form_footer.html' %} +{% endblock %} + +{% block post_login %} + {{ block.super }} +
+
+
+{% endblock %} diff --git a/horizon/templates/auth/totp.html b/horizon/templates/auth/totp.html new file mode 100644 index 0000000000..536d886cdd --- /dev/null +++ b/horizon/templates/auth/totp.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "Log in" %}{% endblock %} + +{% block body_id %}splash{% endblock %} + +{% block content %} + {% include 'auth/_totp_page.html' %} +{% endblock %} + +{% block footer %} + {% include '_login_footer.html' %} +{% endblock %} diff --git a/openstack_auth/defaults.py b/openstack_auth/defaults.py index 1495f52c3e..41cfe539e6 100644 --- a/openstack_auth/defaults.py +++ b/openstack_auth/defaults.py @@ -178,3 +178,5 @@ POLICY_FILES_PATH = '' POLICY_FILES = {} POLICY_DIRS = {} DEFAULT_POLICY_FILES = {} + +OPENSTACK_KEYSTONE_MFA_TOTP_ENABLED = False diff --git a/openstack_auth/exceptions.py b/openstack_auth/exceptions.py index 5925981c38..f6b4eacdcc 100644 --- a/openstack_auth/exceptions.py +++ b/openstack_auth/exceptions.py @@ -46,3 +46,7 @@ class KeystoneCredentialsException(KeystoneAuthException): class KeystonePassExpiredException(KeystoneAuthException): """The password is expired and needs to be changed.""" + + +class KeystoneTOTPRequired(KeystoneAuthException): + """The passcode TOTP is requirede to authentificate.""" diff --git a/openstack_auth/forms.py b/openstack_auth/forms.py index c36a8e3d34..61fd75dc03 100644 --- a/openstack_auth/forms.py +++ b/openstack_auth/forms.py @@ -155,6 +155,15 @@ class Login(django_auth_forms.AuthenticationForm): if utils.allow_expired_passowrd_change(): raise raise forms.ValidationError(exc) + except exceptions.KeystoneTOTPRequired as exc: + LOG.info('Login failed for user "%(username)s" using domain ' + '"%(domain)s", remote address %(remote_ip)s: TOTP' + 'required.', + {'username': username, 'domain': domain, + 'remote_ip': utils.get_client_ip(self.request)}) + if settings.OPENSTACK_KEYSTONE_MFA_TOTP_ENABLED: + raise + raise forms.ValidationError(exc) except exceptions.KeystoneAuthException as exc: LOG.info('Login failed for user "%(username)s" using domain ' '"%(domain)s", remote address %(remote_ip)s.', @@ -244,3 +253,54 @@ class Password(forms.Form): raise forms.ValidationError( _("Unable to update the user password.")) return self.cleaned_data + + +class TimeBasedOneTimePassword(forms.Form): + """Form used for TOTP authentification""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields = collections.OrderedDict([ + ( + 'totp', + forms.CharField(label=_("Passcode"), + required=True, + widget=forms.TextInput( + attrs={"autofocus": "autofocus"})) + ) + ]) + + @sensitive_variables('totp') + def clean(self): + default_domain = settings.OPENSTACK_KEYSTONE_DEFAULT_DOMAIN + request = self.initial['request'] + domain = self.initial['domain'] + if domain == "" or domain is None: + domain = default_domain + username = self.initial['username'] + receipt = self.initial['receipt'] + region_id = self.initial['region'] + passcode = self.cleaned_data.get('totp') + try: + region = get_region_endpoint(region_id) + LOG.info(region) + except (ValueError, IndexError, TypeError): + raise forms.ValidationError("Invalid region %r" % region_id) + try: + self.cleaned_data['region'] = region + self.user_cache = authenticate(request=request, + receipt=receipt, + username=username, + passcode=passcode, + user_domain_name=domain, + auth_url=region) + LOG.info('Login successful for user "%(username)s" using domain ' + '"%(domain)s", remote address %(remote_ip)s.', + {'username': username, 'domain': domain, + 'remote_ip': utils.get_client_ip(request)}) + except exceptions.KeystoneNoBackendException as exc: + LOG.info(exc) + raise forms.ValidationError('KeystoneNoBackendException') + except Exception as exc: + LOG.info(exc) + raise forms.ValidationError(exc) + return self.cleaned_data diff --git a/openstack_auth/plugin/__init__.py b/openstack_auth/plugin/__init__.py index 75e5e852e0..7138f70ddb 100644 --- a/openstack_auth/plugin/__init__.py +++ b/openstack_auth/plugin/__init__.py @@ -14,9 +14,11 @@ from openstack_auth.plugin.base import BasePlugin from openstack_auth.plugin.k2k import K2KAuthPlugin from openstack_auth.plugin.password import PasswordPlugin from openstack_auth.plugin.token import TokenPlugin +from openstack_auth.plugin.totp import TotpPlugin __all__ = ['BasePlugin', 'PasswordPlugin', 'TokenPlugin', - 'K2KAuthPlugin'] + 'K2KAuthPlugin', + 'TotpPlugin'] diff --git a/openstack_auth/plugin/base.py b/openstack_auth/plugin/base.py index 00ffe020ab..4a2865be30 100644 --- a/openstack_auth/plugin/base.py +++ b/openstack_auth/plugin/base.py @@ -133,6 +133,24 @@ class BasePlugin(object, metaclass=abc.ABCMeta): raise exc msg = _('Invalid credentials.') raise exceptions.KeystoneCredentialsException(msg) + except (keystone_exceptions.MissingAuthMethods) as exc: + msg = str(exc) + LOG.debug(msg) + try: + for required in exc.required_auth_methods: + if (len(required) == 2 and + 'totp' in required and + 'password' in required): + receipt = exc.receipt + msg = _('Authentication via TOTP is required.') + exc = exceptions.KeystoneTOTPRequired(msg) + exc.receipt = receipt + raise exc + msg = _("An error occurred authenticating. " + "Please try again later.") + raise exceptions.KeystoneAuthException(msg) + except Exception as exc: + raise exc except (keystone_exceptions.ClientException, keystone_exceptions.AuthorizationFailure) as exc: msg = _("An error occurred authenticating. " diff --git a/openstack_auth/plugin/totp.py b/openstack_auth/plugin/totp.py new file mode 100644 index 0000000000..7f47efb716 --- /dev/null +++ b/openstack_auth/plugin/totp.py @@ -0,0 +1,43 @@ +# 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 keystoneauth1.identity import v3 as v3_auth + +from openstack_auth.plugin import base + +LOG = logging.getLogger(__name__) + +__all__ = ['TotpPlugin'] + + +class TotpPlugin(base.BasePlugin): + + def get_plugin(self, auth_url=None, username=None, passcode=None, + user_domain_name=None, receipt=None, **kwargs): + if not all((auth_url, username, passcode, user_domain_name, receipt)): + return None + LOG.debug('Attempting to authenticate with time-based one time' + ' password for %s', username) + + auth = v3_auth.TOTP( + auth_url=auth_url, + username=username, + passcode=passcode, + user_domain_name=user_domain_name, + unscoped=True + ) + auth.add_method(v3_auth.ReceiptMethod(receipt=receipt)) + + return auth diff --git a/openstack_auth/tests/data_v3.py b/openstack_auth/tests/data_v3.py index f53f11ee29..2526b1ad7b 100644 --- a/openstack_auth/tests/data_v3.py +++ b/openstack_auth/tests/data_v3.py @@ -279,6 +279,48 @@ def generate_test_data(service_providers=False, endpoint='localhost'): body=unscoped_token_dict ) + # TOTP + unscoped_totp_token_dict = { + 'token': { + 'methods': ['password', 'totp'], + 'expires_at': expiration, + 'user': { + 'id': user_dict['id'], + 'name': user_dict['name'], + 'domain': { + 'id': domain_dict['id'], + 'name': domain_dict['name'] + }, + }, + 'catalog': [keystone_service] + } + } + if service_providers: + unscoped_totp_token_dict['token']['service_providers'] = sp_list + test_data.unscoped_access_info_totp = access.create( + resp=auth_response, + body=unscoped_totp_token_dict + ) + + missing_methods_response_headers = { + 'X-Subject-Token': auth_token, + 'Openstack-Auth-Receipt': auth_token + } + + missing_methods_response_text = """{ + "required_auth_methods": [["totp", "password"]], + "receipt": { + "methods": ["password"], + "expires_at": "2023-08-15T10:31:53.000000Z" + } + }""" + + test_data.missing_methods_response = TestResponse({ + "headers": missing_methods_response_headers, + "status_code": 401, + "text": missing_methods_response_text + }) + # Service Catalog test_data.service_catalog = service_catalog.ServiceCatalogV3( [keystone_service, nova_service]) diff --git a/openstack_auth/tests/templates/auth/totp.html b/openstack_auth/tests/templates/auth/totp.html new file mode 100644 index 0000000000..42180c7d15 --- /dev/null +++ b/openstack_auth/tests/templates/auth/totp.html @@ -0,0 +1,11 @@ + + + + TOTP + + +
{{ csrf_token }} + {{ form.as_p }} +
+ + diff --git a/openstack_auth/tests/unit/test_auth.py b/openstack_auth/tests/unit/test_auth.py index 7cf59a838d..6997ff259b 100644 --- a/openstack_auth/tests/unit/test_auth.py +++ b/openstack_auth/tests/unit/test_auth.py @@ -1441,3 +1441,98 @@ class OpenStackAuthTestsInternalURL(OpenStackAuthTests): class OpenStackAuthTestsAdminURL(OpenStackAuthTests): interface = 'adminURL' + + +class OpenstackAuthTestsTOTP(test.TestCase): + interface = None + + def setUp(self): + super().setUp() + + params = { + 'OPENSTACK_API_VERSIONS': {'identity': 3}, + 'OPENSTACK_KEYSTONE_URL': "http://localhost/identity/v3", + 'OPENSTACK_KEYSTONE_MFA_TOTP_ENABLED': True, + 'AUTHENTICATION_PLUGINS': [ + 'openstack_auth.plugin.totp.TotpPlugin', + 'openstack_auth.plugin.password.PasswordPlugin', + 'openstack_auth.plugin.token.TokenPlugin'], + } + if self.interface: + params['OPENSTACK_ENDPOINT_TYPE'] = self.interface + + override = self.settings(**params) + override.enable() + self.addCleanup(override.disable) + + self.data = data_v3.generate_test_data() + + def get_form_data(self, user): + return {'region': "default", + 'domain': DEFAULT_DOMAIN, + 'password': user.password, + 'username': user.name} + + def get_form_totp_data(self): + return {'totp': "000000"} + + def test_totp_form(self): + user = self.data.user + url = reverse('totp', args=[user.name]) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'totp') + + @mock.patch('keystoneauth1.identity.v3.Password.get_access') + def test_totp_redirect(self, mock_get_access): + user = self.data.user + response = self.data.missing_methods_response + form_data = self.get_form_data(user) + url = reverse('login') + + mock_get_access.side_effect = keystone_exceptions.MissingAuthMethods( + response) + + # GET the page to set the test cookie. + response = self.client.get(url, form_data) + self.assertEqual(response.status_code, 200) + + # POST to the page to acces at TOTP authentification page. + response = self.client.post(url, form_data) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/totp/%s/" % user.name) + + @mock.patch('keystoneauth1.identity.v3.Token.get_access') + @mock.patch('keystoneauth1.identity.v3.BaseAuth.get_access') + @mock.patch('keystoneauth1.identity.v3.Password.get_access') + @mock.patch('keystoneclient.v3.client.Client') + def test_totp_login(self, mock_client, mock_get_access, mock_get_access_, + mock_get_access_token): + projects = [self.data.project_one, self.data.project_two] + user = self.data.user + response = self.data.missing_methods_response + form_data = self.get_form_data(user) + url = reverse('login') + + mock_get_access.side_effect = keystone_exceptions.MissingAuthMethods( + response) + + # Set test cookie: + response = self.client.get(url, form_data) + response = self.client.post(url, form_data) + + form_data = self.get_form_totp_data() + url = response.url + + mock_get_access_.return_value = self.data.unscoped_access_info_totp + mock_client.return_value.projects.list.return_value = projects + mock_get_access_token.return_value = self.data.unscoped_access_info_totp + + # GET the page to set the test cookie. + response = self.client.get(url, form_data) + self.assertEqual(response.status_code, 200) + + # POST to the page to log in. + response = self.client.post(url, form_data) + self.assertRedirects(response, settings.LOGIN_REDIRECT_URL) diff --git a/openstack_auth/tests/urls.py b/openstack_auth/tests/urls.py index eaafce5258..66d06ff864 100644 --- a/openstack_auth/tests/urls.py +++ b/openstack_auth/tests/urls.py @@ -22,5 +22,9 @@ urlpatterns = [ re_path(r"", include('openstack_auth.urls')), re_path(r"^websso/$", views.websso, name='websso'), re_path(r"^$", - generic.TemplateView.as_view(template_name="auth/blank.html")) + generic.TemplateView.as_view(template_name="auth/blank.html")), + re_path(r'^totp/(?P[^/]+)/$', + views.TotpView.as_view(), + name='totp') + ] diff --git a/openstack_auth/urls.py b/openstack_auth/urls.py index 027ce803d7..56dafd2dbb 100644 --- a/openstack_auth/urls.py +++ b/openstack_auth/urls.py @@ -42,6 +42,13 @@ if utils.allow_expired_passowrd_change(): name='password') ) +if settings.OPENSTACK_KEYSTONE_MFA_TOTP_ENABLED: + urlpatterns.append( + re_path(r'^totp/(?P[^/]+)/$', + views.TotpView.as_view(), + name='totp') + ) + if settings.WEBSSO_ENABLED: urlpatterns += [ re_path(r"^websso/$", views.websso, name='websso'), diff --git a/openstack_auth/views.py b/openstack_auth/views.py index 20e65e43b3..29f0e654ae 100644 --- a/openstack_auth/views.py +++ b/openstack_auth/views.py @@ -163,6 +163,11 @@ def login(request): form_class=form, extra_context=extra_context, redirect_authenticated_user=False)(request) + except exceptions.KeystoneTOTPRequired as exc: + res = django_http.HttpResponseRedirect( + reverse('totp', args=[request.POST.get('username')])) + request.session['receipt'] = exc.receipt + request.session['domain'] = request.POST.get('domain') except exceptions.KeystonePassExpiredException as exc: res = django_http.HttpResponseRedirect( reverse('password', args=[exc.user_id])) @@ -484,3 +489,40 @@ class PasswordView(edit_views.FormView): res = django_http.HttpResponseRedirect(self.success_url) set_logout_reason(res, msg) return res + + +class TotpView(edit_views.FormView): + """Logs a user in using a TOTP authentification""" + template_name = 'auth/totp.html' + form_class = forms.TimeBasedOneTimePassword + success_url = settings.LOGIN_REDIRECT_URL + fail_url = "/login/" + + def get_initial(self): + return { + 'request': self.request, + 'username': self.kwargs['user_name'], + 'receipt': self.request.session.get('receipt'), + 'region': self.request.COOKIES.get('login_region'), + 'domain': self.request.session.get('domain'), + } + + def form_valid(self, form): + auth.login(self.request, form.user_cache) + res = django_http.HttpResponseRedirect(self.success_url) + request = self.request + if self.request.user.is_authenticated: + del request.session['receipt'] + auth_user.set_session_from_user(request, request.user) + regions = dict(forms.get_region_choices()) + region = request.user.endpoint + login_region = request.POST.get('region') + region_name = regions.get(login_region) + request.session['region_endpoint'] = region + request.session['region_name'] = region_name + return res + + def form_invalid(self, form): + if 'KeystoneNoBackendException' in str(form.errors): + return django_http.HttpResponseRedirect(self.fail_url) + return super().form_invalid(form) diff --git a/releasenotes/notes/add-totp-support-d14d01b038e5deea.yaml b/releasenotes/notes/add-totp-support-d14d01b038e5deea.yaml new file mode 100644 index 0000000000..69e6894a9b --- /dev/null +++ b/releasenotes/notes/add-totp-support-d14d01b038e5deea.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Add suport for Time-based One-time Passwords.