Automatically redirect to the password change when it's expired

Unfortunately the only way we can know the user_id at this point is
by parsing the error message.

I also refactored the exceptions in openstack_auth to make them use
different classes (but one common superclass).

Partially implements blueprint: allow-users-change-expired-password

Change-Id: Ieceee09db21040b96577db19bd195dc3799e3892
This commit is contained in:
Radomir Dopieralski 2019-07-23 17:11:58 +02:00
parent 1208919617
commit 9d98a0c24d
9 changed files with 121 additions and 16 deletions

View File

@ -31,6 +31,15 @@
</div>
{%endif%}
<fieldset hz-login-finder>
{% if request.COOKIES.logout_reason %}
{% if request.COOKIES.logout_status == "success" %}
<div class="form-group clearfix error help-block alert alert-success" id="logout_reason">
{% else %}
<div class="form-group clearfix error help-block alert alert-danger" id="logout_reason">
{% endif %}
<p>{{ request.COOKIES.logout_reason }}</p>
</div>
{% endif %}
{% include "horizon/common/_form_fields.html" %}
</fieldset>
{% endblock %}

View File

@ -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)

View File

@ -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."""

View File

@ -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.',

View File

@ -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. "

View File

@ -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,

View File

@ -29,10 +29,14 @@ urlpatterns = [
url(r'^switch_keystone_provider/(?P<keystone_provider>[^/]+)/$',
views.switch_keystone_provider,
name='switch_keystone_provider'),
url(r'^password/(?P<user_id>[^/]+)/$', views.PasswordView.as_view(),
name='password'),
]
if utils.allow_expired_passowrd_change():
urlpatterns.append(
url(r'^password/(?P<user_id>[^/]+)/$', views.PasswordView.as_view(),
name='password')
)
if utils.is_websso_enabled():
urlpatterns += [
url(r"^websso/$", views.websso, name='websso'),

View File

@ -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

View File

@ -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.