Skip to content

Commit

Permalink
[feature] Added password expiration #359
Browse files Browse the repository at this point in the history
Closes #359

Co-authored-by: Federico Capoano <[email protected]>
  • Loading branch information
pandafy and nemesifier authored Nov 14, 2023
1 parent f363505 commit 33545bb
Show file tree
Hide file tree
Showing 29 changed files with 1,000 additions and 7 deletions.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pip-delete-this-directory.txt
htmlcov/
.tox/
.coverage
# When running coverage testing
# When running coverage testing
# it creates .coverage-username.123x* files
.coverage.*
.cache
Expand All @@ -53,6 +53,9 @@ coverage.xml
# Django stuff:
*.log

# celery
tests/celerybeat*

# Sphinx documentation
docs/_build/

Expand Down
109 changes: 108 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Setup (integrate in an existing django project)
'drf_yasg',
]
also add ``AUTH_USER_MODEL``, ``SITE_ID`` and ``AUTHENTICATION_BACKENDS``
also add ``AUTH_USER_MODEL``, ``SITE_ID``, ``AUTHENTICATION_BACKENDS`` and ``MIDDLEWARE``
to your ``settings.py``:

.. code-block:: python
Expand All @@ -112,6 +112,31 @@ to your ``settings.py``:
AUTHENTICATION_BACKENDS = [
'openwisp_users.backends.UsersAuthenticationBackend',
]
MIDDLEWARE = [
# Other middlewares
'openwisp_users.middleware.PasswordExpirationMiddleware',
]
Configure celery (you may use a different broker if you want):

.. code-block:: python
# here we show how to configure celery with redis but you can
# use other brokers if you want, consult the celery docs
CELERY_BROKER_URL = 'redis://localhost/1'
CELERY_BEAT_SCHEDULE = {
'password_expiry_email': {
'task': 'openwisp_users.tasks.password_expiration_email',
'schedule': crontab(hour=1, minute=0),
},
}
If you decide to use Redis (as shown in these examples),
install the following python packages.

.. code-block:: shell
pip install redis django-redis
``urls.py``:

Expand Down Expand Up @@ -178,6 +203,15 @@ Create database:
./manage.py migrate
./manage.py createsuperuser
Run celery and celery-beat with the following commands (separate terminal windows are needed):

.. code-block:: shell
cd tests/
celery -A openwisp2 worker -l info
celery -A openwisp2 beat -l info
Launch development server:

.. code-block:: shell
Expand Down Expand Up @@ -305,6 +339,34 @@ command.

The ``select_related`` property can be used to optimize the database query.

``OPENWISP_USERS_USER_PASSWORD_EXPIRATION``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

+--------------+-------------+
| **type**: | ``integer`` |
+--------------+-------------+
| **default**: | ``0`` |
+--------------+-------------+

Number of days after which a user's password will expire.
In other words, it determines when users will be prompted to
change their passwords.

If set to ``0``, this feature is disabled, and users are not
required to change their passwords.

``OPENWISP_USERS_STAFF_USER_PASSWORD_EXPIRATION``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

+--------------+-------------+
| **type**: | ``integer`` |
+--------------+-------------+
| **default**: | ``0`` |
+--------------+-------------+

Similar to `OPENWISP_USERS_USER_PASSWORD_EXPIRATION <#openwisp-users-user-password-expiration>`_,
but for **staff users**.

REST API
--------

Expand Down Expand Up @@ -764,6 +826,51 @@ The authentication backend can also be used as follows:
backend = UsersAuthenticationBackend()
backend.authenticate(request, identifier, password)
Password Validators
-------------------

``openwisp_users.password_validation.PasswordReuseValidator``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

On password change views, the ``PasswordReuseValidator``
ensures that users cannot use their current password as the new password.

You need to add the validator to ``AUTH_PASSWORD_VALIDATORS`` Django
setting as shown below:

.. code-block:: python
# in your-project/settings.py
AUTH_PASSWORD_VALIDATORS = [
# Other password validators
{
"NAME": "openwisp_users.password_validation.PasswordReuseValidator",
},
]
Middlewares
-----------

``openwisp_users.middleware.PasswordExpirationMiddleware``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

When password expiration feature is on
(see `OPENWISP_USERS_USER_PASSWORD_EXPIRATION <#openwisp-users-user-password-expiration>`_
and `OPENWISP_USERS_STAFF_USER_PASSWORD_EXPIRATION <#openwisp-users-staff-user-password-expiration>`_),
this middleware confines the user to the *password change view* until they change their password.

This middleware should come after ``AuthenticationMiddleware`` and ``MessageMiddleware``, as following:

.. code-block:: python
# in your_project/settings.py
MIDDLEWARE = [
# Other middlewares
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'openwisp_users.middleware.PasswordExpirationMiddleware',
]
Django REST Framework Authentication Classes
--------------------------------------------

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
form.password_change p {
overflow: hidden;
padding: 15px;
font-size: 15px;
border-bottom: 1px solid var(--hairline-color);
box-sizing: border-box;
}
form.password_change label {
display: inline-block;
padding: 6px 10px 0 0;
width: 160px;
word-wrap: break-word;
line-height: 1;
font-weight: bold;
}
#main form.password_change .submit-row {
text-align: left;
}
#main form.password_change .default {
float: unset;
}
#main form.password_change .submit-row input {
margin: 0 15px 0 0;
}
#main .submit-row .button {
font-size: 14px;
padding: 0.625rem 1rem;
}
#main .errorlist {
margin: 5px 0;
}
#main .errorlist li {
font-size: 15px;
font-weight: bold;
}

@media (max-width: 767px) {
#main form.password_change label {
padding: 0 0 10px;
width: 100%;
}

#main form.password_change input {
width: 100%;
}

#main form.password_change .submit-row .button {
width: 100%;
margin: 0 0 15px 0;
padding-right: 0;
padding-left: 0;
}
}
2 changes: 2 additions & 0 deletions openwisp_users/accounts/templates/account/base.html
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
{% extends "admin/base_site.html" %}
{% block title %}OpenWISP2{% endblock %}

{% block breadcrumbs %}{% endblock %}
55 changes: 55 additions & 0 deletions openwisp_users/accounts/templates/account/password_change.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{% extends "account/base.html" %}

{% load i18n %}
{% load static %}

{% block title %}{% trans "Change Password" %}{% endblock %}
{% block extrahead %}
<link rel="stylesheet" type="text/css" href="{% static 'openwisp-users/accounts/css/change-password.css' %}" />
{% endblock %}

{% block extrastyle %}
{{ block.super }}
{{ form.media.css }}
{% endblock extrastyle %}

{% block content %}
<h1>{% trans "Change Password" %}</h1>

<form method="POST" action="{% url 'account_change_password' %}" class="password_change">
{% csrf_token %}
<fieldset class="aligned">
<div class="form-row">
{{ form.oldpassword.errors }}
{{ form.oldpassword.label_tag }}
{{ form.oldpassword }}
{% if form.oldpassword.help_text %}
<div class="help" {% if form.oldpassword.id_for_label %} id="{{ form.oldpassword.id_for_label }}_helptext"
{% endif %}>{{ form.oldpassword.help_text|safe }}</div>
{% endif %}
</div>
<div class="form-row">
{{ form.password1.errors }}
{{ form.password1.label_tag }}
{{ form.password1 }}
{% if form.password1.help_text %}
<div class="help" {% if form.password1.id_for_label %} id="{{ form.password1.id_for_label }}_helptext"
{% endif %}>{{ form.password1.help_text|safe }}</div>
{% endif %}
</div>
<div class="form-row">
{{ form.password2.errors }}
{{ form.password2.label_tag }}
{{ form.password2 }}
{% if form.password2.help_text %}
<div class="help" {% if form.password2.id_for_label %} id="{{ form.password2.id_for_label }}_helptext"
{% endif %}>{{ form.password2.help_text|safe }}</div>
{% endif %}
</div>
</fieldset>
<div class="form-row submit-row">
<input type="submit" name="action" value="{% translate 'Change password' %}" class="default button">
<a href="{% url 'account_reset_password' %}" class="button">{% trans "Forgot Password?" %}</a>
</div>
</form>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% extends "account/base.html" %}
{% load i18n %}

{% block extrastyle %}
{{ block.super }}
<style>
.breadcrumbs, .messagelist {
display: none;
}
#content {
margin-top: 10px;
}
#site-name a { cursor: default }
</style>
{% endblock %}

{% block content %}
<h1>{% trans "Your password has been changed successfully." %}</h1>
<h1>{% trans "This web page can be closed." %}</h1>
{% endblock %}
13 changes: 13 additions & 0 deletions openwisp_users/accounts/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from django.views.generic import RedirectView
from django.views.generic.base import TemplateView

from .views import password_change, password_change_success

redirect_view = RedirectView.as_view(url=reverse_lazy('admin:index'))


Expand All @@ -33,6 +35,17 @@
views.confirm_email,
name='account_confirm_email',
),
# password change
path(
'password/change/',
password_change,
name="account_change_password",
),
path(
'password/change/success/',
password_change_success,
name='account_change_password_success',
),
# password reset
path('password/reset/', views.password_reset, name='account_reset_password'),
path(
Expand Down
33 changes: 33 additions & 0 deletions openwisp_users/accounts/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from allauth.account.forms import ChangePasswordForm as BaseChangePasswordForm
from allauth.account.views import PasswordChangeView as BasePasswordChangeView
from django import forms
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.decorators import login_required
from django.urls import reverse_lazy
from django.views.generic.base import TemplateView


class ChangePasswordForm(BaseChangePasswordForm):
next = forms.CharField(widget=forms.HiddenInput, required=False)


class PasswordChangeView(BasePasswordChangeView):
form_class = ChangePasswordForm
template_name = 'account/password_change.html'
success_url = reverse_lazy('account_change_password_success')

def get_success_url(self):
if self.request.POST.get(REDIRECT_FIELD_NAME):
return self.request.POST.get(REDIRECT_FIELD_NAME)
return super().get_success_url()

def get_initial(self):
data = super().get_initial()
data['next'] = self.request.GET.get(REDIRECT_FIELD_NAME)
return data


password_change = login_required(PasswordChangeView.as_view())
password_change_success = login_required(
TemplateView.as_view(template_name='account/password_change_success.html')
)
4 changes: 3 additions & 1 deletion openwisp_users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ class UserAdmin(MultitenantAdminMixin, BaseUserAdmin, BaseAdmin):
add_form = UserCreationForm
form = UserChangeForm
ordering = ['-date_joined']
readonly_fields = ['last_login', 'date_joined']
readonly_fields = ['last_login', 'date_joined', 'password_updated']
list_display = [
'username',
'email',
Expand Down Expand Up @@ -476,6 +476,8 @@ def queryset(self, request, queryset):
additional_fields = ['bio', 'url', 'company', 'location', 'phone_number', 'birth_date']
UserAdmin.fieldsets[1][1]['fields'] = base_fields + additional_fields
UserAdmin.fieldsets.insert(3, ('Internal', {'fields': ('notes',)}))
primary_fields = list(UserAdmin.fieldsets[0][1]['fields'])
UserAdmin.fieldsets[0][1]['fields'] = primary_fields + ['password_updated']
UserAdmin.add_fieldsets[0][1]['fields'] = (
'username',
'email',
Expand Down
1 change: 0 additions & 1 deletion openwisp_users/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ def authenticate(self, request, username=None, password=None, **kwargs):
return None
if user.check_password(password) and self.user_can_authenticate(user):
return user
return None

def get_users(self, identifier):
conditions = Q(email=identifier) | Q(username=identifier)
Expand Down
Loading

0 comments on commit 33545bb

Please sign in to comment.