[Django]-Is it possible to implement a "change password at next logon" type feature in the django admin?

35👍

I’m actually in the process of doing this myself. You need three components: a user profile (if not already in use on your site), a middleware component, and a pre_save signal.

My code for this is in an app named ‘accounts’.

# myproject/accounts/models.py

from django.db import models
from django.db.models import signals
from django.contrib.auth.models import User

class UserProfile(models.Model):
    user = models.ForeignKey(User, unique=True)
    force_password_change = models.BooleanField(default=False)

def create_user_profile_signal(sender, instance, created, **kwargs):
    if created:
        UserProfile.objects.create(user=instance)

def password_change_signal(sender, instance, **kwargs):
    try:
        user = User.objects.get(username=instance.username)
        if not user.password == instance.password:
          profile = user.get_profile()
          profile.force_password_change = False
          profile.save()
    except User.DoesNotExist:
        pass

signals.pre_save.connect(password_change_signal, sender=User, dispatch_uid='accounts.models')

signals.post_save.connect(create_user_profile_signal, sender=User, dispatch_uid='accounts.models')

First, we create a UserProfile with a foreign key to User. The force_password_change boolean will, as its name describes, be set to true for a user whenever you want to force them to change their password. You could do anything here though. In my organization, we also chose to implement a mandatory change every 90 days, so I also have a DateTimeField that stores the last time a user changed their password. You then set that in the pre_save signal, password_changed_signal.

Second, we have the create_user_profile_signal. This is mostly added just for completeness. If you’re just now adding user profiles into your project, you’ll need a post_save signal that will create a UserProfile every time a User is created. This accomplishes that task.

Third, we have the password_changed_signal. This is a pre_save signal because at this point in the process the actual row in the User table hasn’t be updated. Therefore, we can access both the previous password and the new password about to be saved. If the two don’t match, that means the user has changed their password, and we can then reset the force_password_change boolean. This would be the point, also where you would take care of any other things you’ve added such as setting the DateTimeField previously mentioned.

The last two lines attach the two functions to their appropriate signals.

If you haven’t already, you will also need to add the following line to your project’s settings.py (changing the app label and model name to match your setup):

AUTH_PROFILE_MODULE = 'accounts.UserProfile'

That covers the basics. Now we need a middleware component to check the status of our force_password_change flag (and any other necessary checks).

# myproject/accounts/middleware.py

from django.http import HttpResponseRedirect

import re

class PasswordChangeMiddleware:
    def process_request(self, request):
        if request.user.is_authenticated() and \
            re.match(r'^/admin/?', request.path) and \
            not re.match(r'^/admin/password_change/?', request.path):

            profile = request.user.get_profile()
            if profile.force_password_change:
                return HttpResponseRedirect('/admin/password_change/')

This very simple middleware hooks into the process_request stage of the page loading process. It checks that 1) the user has already logged in, 2) they are trying to access some page in the admin, and 3) the page they are accessing is not the password change page itself (otherwise, you’d get an infinite loop of redirects). If all of these are true and the force_password_change flag has been set to True, then the user is redirected to the password change page. They will not be able to navigate anywhere else until they change their password (firing the pre_save signal discussed previously).

Finally, you just need to add this middleware to your project’s settings.py (again, changing the import path as necessary):

MIDDLEWARE_CLASSES = (
    # Other middleware here
    'myproject.accounts.middleware.PasswordChangeMiddleware',
)

11👍

I have used Chris Pratt’s solution, with a little change: instead of using a middleware, that’d be executed for every page with the consequent resource use, I figured I’d just intercept the login view.

In my urls.py I have added this to my urlpatterns:

url(r'^accounts/login/$', 'userbase.views.force_pwd_login'),

then I added the following to userbase.views:

def force_pwd_login(request, *args, **kwargs):
    response = auth_views.login(request, *args, **kwargs)
    if response.status_code == 302:
        #We have a user
        try:
            if request.user.get_profile().force_password_change:
                return redirect('django.contrib.auth.views.password_change')
        except AttributeError: #No profile?
            pass
    return response

It seems to work flawlessly on Django 1.2, but I have no reason to believe 1.3+ should have problems with it.

👤pgcd

3👍

This is the middleware I use with Django 1.11 :

# myproject/accounts/middleware.py

from django.http import HttpResponseRedirect
from django.urls import reverse


class PasswordChangeMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        next = reverse('client:password-update')
        if request.user.is_authenticated() and request.path != next:
            if request.user.account.force_password_change:
                return HttpResponseRedirect(next)

        return response

Still adding it to the settings middleware list :

MIDDLEWARE_CLASSES = (
    # Other middleware here
    'myproject.accounts.middleware.PasswordChangeMiddleware',
)

2👍

I spent 2 days on this issue recently, and a new solution came out.
Hopefully it’s useful.

Just as above said, a new user model created.

newuser/models.py

class Users(AbstractUser):
    default_pwd_updated = models.NullBooleanField(default=None, editable=False)
    pwd_update_time = models.DateTimeField(editable=False, null=True, default=None)  # reserved column to support further interval password (such as 60 days) update policy

     def set_password(self, raw_password):
        if self.default_pwd_updated is None:
            self.default_pwd_updated = False
        elif not self.default_pwd_updated:
            self.default_pwd_updated = True
            self.pwd_update_time = timezone.now()
        else:
            self.pwd_update_time = timezone.now()
        super().set_password(raw_password)

Set this model as the AUTH_USER_MODEL.

[project]/settings.py

AUTH_USER_MODEL = 'newuser.Users'

Now you just need to customize LoginView and some methods in AdminSite.

[project]/admin.py

from django.contrib.admin import AdminSite
from django.contrib.auth.views import LoginView
from django.utils.translation import gettext as _, gettext_lazy
from django.urls import reverse
from django.views.decorators.cache import never_cache
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.http import HttpResponseRedirect


class NewLoginView(LoginView):
    def get_redirect_url(self):
        if self.request.method == "POST" and self.request.user.get_username()\
                and not self.request.user.default_pwd_updated:
            redirect_to = reverse("admin:password_change")
        else:
            redirect_to = self.request.POST.get(
                self.redirect_field_name,
                self.request.GET.get(self.redirect_field_name, '')
            )
        return redirect_to


class NewAdminSite(AdminSite):
    site_header = site_title = gettext_lazy("Customized Admin Site")

    def __init__(self, name="admin"):
        super().__init__(name)

    @never_cache
    def login(self, request, extra_context=None):
        """
        Display the login form for the given HttpRequest.
        """
        if request.method == 'GET' and self.has_permission(request):
            # Already logged-in, redirect to admin index
            if request.user.get_username() and not request.user.default_pwd_updated:
                # default password not changed, force to password_change view
                path = reverse('admin:password_change', current_app=self.name)
            else:
                path = reverse('admin:index', current_app=self.name)
            return HttpResponseRedirect(path)

        from django.contrib.auth.views import LoginView
        from django.contrib.admin.forms import AdminAuthenticationForm
        context = {
            **self.each_context(request),
            'title': _('Log in'),
            'app_path': request.get_full_path(),
            'username': request.user.get_username(),
        }
        if (REDIRECT_FIELD_NAME not in request.GET and
                REDIRECT_FIELD_NAME not in request.POST):
            context[REDIRECT_FIELD_NAME] = reverse('admin:index', current_app=self.name)
        context.update(extra_context or {})

        defaults = {
            'extra_context': context,
            'authentication_form': self.login_form or AdminAuthenticationForm,
            'template_name': self.login_template or 'admin/login.html',
        }
        request.current_app = self.name
        return NewLoginView.as_view(**defaults)(request) # use NewLoginView

    @never_cache
    def index(self, request, extra_context=None):
        if request.user.get_username() and not request.user.default_pwd_updated:
            # if default password not updated, force to password_change page
            context = self.each_context(request)
            context.update(extra_context or {})
            return self.password_change(request, context)
        return super().index(request, extra_context)


admin_site = NewAdminSite(name="admin")

NOTE: if you intend to use custom template for changing default password, you could override each_context method and then determine which template should be used up to the flag force_pwd_change.

[project]/admin.py

def using_default_password(self, request):
    if self.has_permission(request) and request.user.get_username() and not request.user.default_pwd_updated:
            return True
    return False

def each_context(self, request):
    context = super().each_context(request)
    context["force_pwd_change"] = self.using_default_password(request)
    return context

1👍

From a thread on the Django Users mailing list:

This isn’t ideal, but it should work
(or prompt someone to propose
something better).

Add a one-to-one table for the user,
with a field containing the initial
password (encrypted, of course, so it
looks like the password in the
auth_user table).

When the user logs in, have the login
page check to see if the passwords
match. If they do, redirect to the
password change page instead of the
normal redirect page.

0👍

Checkout this simple package based on session (Tested with django 1.8). https://github.com/abdullatheef/django_force_reset_password

Create custom view in myapp.views.py

class PassWordReset(admin.AdminSite):

    def login(self, request, extra_context=None):
        if request.method == 'POST':
            response = super(PassWordReset, self).login(request, extra_context=extra_context)
            if response.status_code == 302 and request.user.is_authenticated():
                if not "fpr" in request.session or request.session['fpr']:
                    request.session['fpr'] = True
                    return HttpResponseRedirect("/admin/password_change/")
            return response
        return super(PassWordReset, self).login(request, extra_context=extra_context)

    def password_change(self, request, extra_context=None):
        if request.method == 'POST':
            response = super(PassWordReset, self).password_change(request, extra_context=extra_context)
            if response.status_code == 302 and request.user.is_authenticated():
                request.session['fpr'] = False
            return response
        return super(PassWordReset, self).password_change(request, extra_context=extra_context)


pfr_login = PassWordReset().login
pfr_password_change = PassWordReset().admin_view(PassWordReset().password_change, cacheable=True)

Then in project/urls.py

from myapp.views import pfr_password_change, pfr_login
urlpatterns = [
    ......
    url(r'^admin/login/$', pfr_login),
    url(r'^admin/password_change/$', pfr_password_change),
    url(r'^admin/', admin.site.urls),
    ....
]

Then add this middleware myapp/middleware.py

class FPRCheck(object):
    def process_request(self, request):
        if request.user.is_authenticated() \
                and re.match(r'^/admin/?', request.path) \
                and (not "fpr" in request.session or ("fpr" in request.session and request.session['fpr'])) \
                and not re.match(r"/admin/password_change|/admin/logout", request.path):
            return HttpResponseRedirect("/admin/password_change/")

Order of middleware

MIDDLEWARE_CLASSES = [
    ....

    'myapp.middleware.FPRCheck'
    ]

Note

  • This will not need any extra model.
  • Also work with any Session Engine.
  • No db query inside middleware.

Leave a comment