+
+
+ {% block login_header %}
+
+ {% trans 'TOTP authentification' %}
+
+ {% 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" %}
+
+
+
+
+
+{% 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
+
+
+
+
+
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.