diff --git a/horizon/templates/auth/_password_form.html b/horizon/templates/auth/_password_form.html index 8404490b9c..45ed92011c 100644 --- a/horizon/templates/auth/_password_form.html +++ b/horizon/templates/auth/_password_form.html @@ -31,6 +31,15 @@ {%endif%}
+ {% if request.COOKIES.logout_reason %} + {% if request.COOKIES.logout_status == "success" %} +
+ {% else %} +
+ {% endif %} +

{{ request.COOKIES.logout_reason }}

+
+ {% endif %} {% include "horizon/common/_form_fields.html" %}
{% endblock %} diff --git a/openstack_auth/backend.py b/openstack_auth/backend.py index 39030e6f5f..10b865a7b5 100644 --- a/openstack_auth/backend.py +++ b/openstack_auth/backend.py @@ -79,7 +79,7 @@ class KeystoneBackend(object): "service appears to have expired before it was " "issued. This may indicate a problem with either your " "server or client configuration.") - raise exceptions.KeystoneAuthException(msg) + raise exceptions.KeystoneTokenExpiredException(msg) return True def _get_auth_backend(self, auth_url, **kwargs): @@ -93,7 +93,7 @@ class KeystoneBackend(object): LOG.warning('No authentication backend could be determined to ' 'handle the provided credentials. This is likely a ' 'configuration error that should be addressed.') - raise exceptions.KeystoneAuthException(msg) + raise exceptions.KeystoneNoBackendException(msg) def authenticate(self, request, auth_url=None, **kwargs): """Authenticates a user via the Keystone Identity API.""" @@ -150,7 +150,7 @@ class KeystoneBackend(object): scoped_auth_ref = domain_auth_ref elif not scoped_auth_ref and not domain_auth_ref: msg = _('You are not authorized for any projects or domains.') - raise exceptions.KeystoneAuthException(msg) + raise exceptions.KeystoneNoProjectsException(msg) # Check expiry for our new scoped token. self._check_auth_expiry(scoped_auth_ref) diff --git a/openstack_auth/exceptions.py b/openstack_auth/exceptions.py index cce0d6543d..5925981c38 100644 --- a/openstack_auth/exceptions.py +++ b/openstack_auth/exceptions.py @@ -14,3 +14,35 @@ class KeystoneAuthException(Exception): """Generic error class to identify and catch our own errors.""" + + +class KeystoneTokenExpiredException(KeystoneAuthException): + """The authentication token issued by the Identity service has expired.""" + + +class KeystoneNoBackendException(KeystoneAuthException): + """No backend could be determined to handle the provided credentials.""" + + +class KeystoneNoProjectsException(KeystoneAuthException): + """You are not authorized for any projects or domains.""" + + +class KeystoneRetrieveProjectsException(KeystoneAuthException): + """Unable to retrieve authorized projects.""" + + +class KeystoneRetrieveDomainsException(KeystoneAuthException): + """Unable to retrieve authorized domains.""" + + +class KeystoneConnectionException(KeystoneAuthException): + """Unable to establish connection to keystone endpoint.""" + + +class KeystoneCredentialsException(KeystoneAuthException): + """Invalid credentials.""" + + +class KeystonePassExpiredException(KeystoneAuthException): + """The password is expired and needs to be changed.""" diff --git a/openstack_auth/forms.py b/openstack_auth/forms.py index 186e472826..49470c8620 100644 --- a/openstack_auth/forms.py +++ b/openstack_auth/forms.py @@ -144,6 +144,15 @@ class Login(django_auth_forms.AuthenticationForm): '"%(domain)s", remote address %(remote_ip)s.', {'username': username, 'domain': domain, 'remote_ip': utils.get_client_ip(self.request)}) + except exceptions.KeystonePassExpiredException as exc: + LOG.info('Login failed for user "%(username)s" using domain ' + '"%(domain)s", remote address %(remote_ip)s: password' + ' expired.', + {'username': username, 'domain': domain, + 'remote_ip': utils.get_client_ip(self.request)}) + if utils.allow_expired_passowrd_change(): + 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.', diff --git a/openstack_auth/plugin/base.py b/openstack_auth/plugin/base.py index eb0451a1ff..215b5fd708 100644 --- a/openstack_auth/plugin/base.py +++ b/openstack_auth/plugin/base.py @@ -12,6 +12,7 @@ import abc import logging +import re from django.utils.translation import ugettext_lazy as _ from keystoneauth1 import exceptions as keystone_exceptions @@ -90,7 +91,7 @@ class BasePlugin(object): except (keystone_exceptions.ClientException, keystone_exceptions.AuthorizationFailure): msg = _('Unable to retrieve authorized projects.') - raise exceptions.KeystoneAuthException(msg) + raise exceptions.KeystoneRetrieveProjectsException(msg) def list_domains(self, session, auth_plugin, auth_ref=None): try: @@ -99,7 +100,7 @@ class BasePlugin(object): except (keystone_exceptions.ClientException, keystone_exceptions.AuthorizationFailure): msg = _('Unable to retrieve authorized domains.') - raise exceptions.KeystoneAuthException(msg) + raise exceptions.KeystoneRetrieveDomainsException(msg) def get_access_info(self, keystone_auth): """Get the access info from an unscoped auth @@ -118,12 +119,21 @@ class BasePlugin(object): except keystone_exceptions.ConnectFailure as exc: LOG.error(str(exc)) msg = _('Unable to establish connection to keystone endpoint.') - raise exceptions.KeystoneAuthException(msg) + raise exceptions.KeystoneConnectionException(msg) except (keystone_exceptions.Unauthorized, keystone_exceptions.Forbidden, keystone_exceptions.NotFound) as exc: - LOG.debug(str(exc)) - raise exceptions.KeystoneAuthException(_('Invalid credentials.')) + msg = str(exc) + LOG.debug(msg) + match = re.match(r"The password is expired and needs to be changed" + r" for user: ([^.]*)[.].*", msg) + if match: + exc = exceptions.KeystonePassExpiredException( + _('Password expired.')) + exc.user_id = match.group(1) + raise exc + msg = _('Invalid credentials.') + raise exceptions.KeystoneCredentialsException(msg) except (keystone_exceptions.ClientException, keystone_exceptions.AuthorizationFailure) as exc: msg = _("An error occurred authenticating. " diff --git a/openstack_auth/tests/unit/test_auth.py b/openstack_auth/tests/unit/test_auth.py index 386f3181bb..a668f2a7f0 100644 --- a/openstack_auth/tests/unit/test_auth.py +++ b/openstack_auth/tests/unit/test_auth.py @@ -772,6 +772,35 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, self.assertContains(response, 'option value="Default"') settings.OPENSTACK_KEYSTONE_DOMAIN_DROPDOWN = False + def test_password_expired(self): + user = self.data.user + form_data = self.get_form_data(user) + + class ExpiredException(keystone_exceptions.Unauthorized): + http_status = 401 + message = ("The password is expired and needs to be changed" + " for user: %s." % user.id) + + exc = ExpiredException() + self._mock_client_password_auth_failure(user.name, user.password, exc) + self.mox.ReplayAll() + + url = reverse('login') + + # 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) + + # This fails with TemplateDoesNotExist for some reason. + # self.assertRedirects(response, reverse('password', args=[user.id])) + # so instead we check for the redirect manually: + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/password/%s/" % user.id) + class OpenStackAuthTestsWebSSO(OpenStackAuthTestsMixin, OpenStackAuthFederatedTestsMixin, diff --git a/openstack_auth/urls.py b/openstack_auth/urls.py index c030e3c3c0..f1ac47c1aa 100644 --- a/openstack_auth/urls.py +++ b/openstack_auth/urls.py @@ -29,10 +29,14 @@ urlpatterns = [ url(r'^switch_keystone_provider/(?P[^/]+)/$', views.switch_keystone_provider, name='switch_keystone_provider'), - url(r'^password/(?P[^/]+)/$', views.PasswordView.as_view(), - name='password'), ] +if utils.allow_expired_passowrd_change(): + urlpatterns.append( + url(r'^password/(?P[^/]+)/$', views.PasswordView.as_view(), + name='password') + ) + if utils.is_websso_enabled(): urlpatterns += [ url(r"^websso/$", views.websso, name='websso'), diff --git a/openstack_auth/utils.py b/openstack_auth/utils.py index df64f50c8c..60ce32b87b 100644 --- a/openstack_auth/utils.py +++ b/openstack_auth/utils.py @@ -117,6 +117,11 @@ def get_keystone_client(): return client_v3 +def allow_expired_passowrd_change(): + """Checks if users should be able to change their expired passwords.""" + return getattr(settings, 'ALLOW_USERS_CHANGE_EXPIRED_PASSWORD', True) + + def is_websso_enabled(): """Websso is supported in Keystone version 3.""" return settings.WEBSSO_ENABLED diff --git a/openstack_auth/views.py b/openstack_auth/views.py index b41eff0a9a..9e83789269 100644 --- a/openstack_auth/views.py +++ b/openstack_auth/views.py @@ -19,6 +19,7 @@ from django.contrib.auth import views as django_auth_views from django.contrib import messages from django import http as django_http from django import shortcuts +from django.urls import reverse from django.utils import functional from django.utils import http from django.utils.translation import ugettext_lazy as _ @@ -112,12 +113,18 @@ def login(request): else: template_name = 'auth/login.html' - res = django_auth_views.LoginView.as_view( - template_name=template_name, - redirect_field_name=auth.REDIRECT_FIELD_NAME, - form_class=form, - extra_context=extra_context, - redirect_authenticated_user=False)(request) + try: + res = django_auth_views.LoginView.as_view( + template_name=template_name, + redirect_field_name=auth.REDIRECT_FIELD_NAME, + form_class=form, + extra_context=extra_context, + redirect_authenticated_user=False)(request) + except exceptions.KeystonePassExpiredException as exc: + res = django_http.HttpResponseRedirect( + reverse('password', args=[exc.user_id])) + msg = _("Your password has expired. Please set a new password.") + res.set_cookie('logout_reason', msg, max_age=10) # Save the region in the cookie, this is used as the default # selected region next time the Login form loads.