Wednesday, June 9, 2010

Add a button to Django admin to login as a user (without the password)

Django correctly stores user passwords as md5 hashes by default. This is great for security; there is zero chance that a password could be exposed via flaw in the site, attack, disgruntled employee, whatever. But what if you had a use case where you wanted to login as user without a password?

The use case I have in mind is allowing admin users to login as a user via the Django admin application. This could be very useful for reproducing bugs or verifying what a particular user is seeing. Without knowing the user's password, the only way for an admin to login as them would be to reset their password, login, do their bussiness, and then email the user the new password. Hardly ideal.

Adding a button to the user page in admin is easy. The user model is in the auth application, so all you have to do is create a file called admin/auth/change_form.html in your templates directory. There, you can extend the base change_form.html for the User model. Note: root around in the /usr/lib/pymodules/python2.6/django/contrib/admin/templates directory for an idea of what files you can extend.

{% extends "admin/change_form.html" %}

{% block object-tools %}
{% if change %}{% if not is_popup %}
  <ul class="object-tools">
    <li><a href="history/" class="historylink">History</a></li>
    <li><a href="/login/user/{{ object_id }}?hash={{ 'user'|hash:object_id }}">Login</a></li>
  </ul>
{% endif %}{% endif %}
{% endblock %}

In this case, the relative URL for the login would be the /login/user/$id. If you made the URL absolute, you could provide an alternate domain name, which would allow you a separate cookie, so you could be logged in as different users in both admin and the application at the same time.

What's that hash parameter? It's just a security feature to make sure an attacker cannot access this URL without knowing the secret key. The filter definition looks like this:

from django import template

register = template.Library()  

@register.filter()
def hash(type, id):
    hash = hashlib.md5()
    hash.update("%s:%s:%s" % (type, id, settings.ADMIN_HASH_SECRET))
    return hash.hexdigest().upper()

The URL is routed in typical fashion via urls.py.

    url(r'^login/user/(?P<user_id>[\d_]+)$', admin.login_as_user),

Finally, here is the view that implements the login securely.

from django.conf import settings
from django.http import HttpResponseRedirect
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.contrib.auth import login, authenticate

# the same filter that I called in the template
from search.helpers.tags import logic

def login_as_user(request, user_id):

    # security check; don't let unauthorised people login
    request_hash = request.REQUEST.get("hash", "")
    if request_hash != logic.hash("user", user_id):
        raise Exception("invalid hash value")
    
    user = User.objects.get(id=user_id)
    
    # ADMIN_HASH_SECRET is set in settings.py, can be any secret string 
    user = authenticate(username=user.username, password=settings.ADMIN_HASH_SECRET)
    login(request, user)    
    
    return HttpResponseRedirect(reverse("home"))

The Django login() method does the work of logging the user in against whatever backed you have configured, just as if they logged in manually. However, authenticate() is necessary, and by default requires that the actual user's password be passed in. As mentioned previously, this is a big problem because we don't know the user's password; it's stored as a one-way hash.

It turns out to be not such a big problem after all, as Django provides an easy mechanism to extend the authentication module. First, you define your authenticator.

from django.conf import settings
from django.contrib.auth.models import User

class LoginAsUserBackend:
    """
    Allows admins to login as a user without knowing the password.     
    Will authenticate any username, given the password of settings.ADMIN_HASH_SECRET
    """
    
    def authenticate(self, username=None, password=None):
        if settings.ADMIN_HASH_SECRET != "" and password == settings.ADMIN_HASH_SECRET:
            try:
                return User.objects.get(username=username)
            except:
                pass
        return None

    def get_user(self, user_id):
        try:
            return User.objects.get(pk=user_id)
        except:
            return None

Django authenticators are called in serial; so my version will be called first, and then if that fails the base Django authenticator will have a go. In my case, I'm allowing any user to login with the secret stored in the settings file. My reasoning is that if they know that secret, they would be able to exploit my new login mechanism anyway.

Then, you just add your new authenticator into the mix in settings.py.

...
AUTHENTICATION_BACKENDS = (
    'django.contrib.auth.backends.ModelBackend',
    'my_application.path.to.my.authenticator.LoginAsUserBackend'
    )
...

9 comments:

  1. Nice - you should publish it as a standalone Django application

    ReplyDelete
  2. This is a cool hack, but its overkill. You can do the same thing in just one tiny view: http://copiousfreetime.blogspot.com/2006/12/django-su.html

    ReplyDelete
  3. That example is from 2006, I can't get it to work in Django 1.1. Specifically, the line "request.session[SESSION_KEY] = user.id" definitely does not work any more.

    Also, checking if the user is supseruser is cool, but that requires that you over-ride the admin session. My solution allows you to remain logged in to admin at the same time.

    ReplyDelete
  4. It works just fine in 1.1 and 1.2.

    Perhaps you're missing:
    from django.contrib.auth import SESSION_KEY

    If you do implement it, you should also add a check along these lines:
    if su_user.is_superuser:
    return HttpResponseForbidden('sudo to a superuser is not permitted')

    ReplyDelete
  5. Weird, I thought modern versions of Django hashed passwords with SHA-1 and only supported MD5 as a (temporary) backwards compatible hashing format for legacy upgrades.

    ReplyDelete
  6. I was assuming they were MD5, just from looking at the output. They may very well be SHA-1. That's just a side note though, my solution doesn't rely on that.

    ReplyDelete
  7. The big bonus is that Django use a salt in the password encryption so the "rainbow tables" can not be used for password retrieval.

    ReplyDelete
  8. ^^ clearly someone just learned about salt and was looking for a place to show off their knowledge... salt is pretty standard, not a "big bonus," and if you put "rainbow tables" in quotes then I'm pretty sure you don't know much about them... This post does not regard the encryption methods and their breakability so much as the ability to switch users within Django.

    While this allows you to stay logged in as admin while you are SU'd, I'm not sure that's necessary, or even the best idea. I plan to use this the SU capability for testing, error duplication, etc, and I would feel more comfortable logging in as another user completely while doing this.

    Furthermore, in line with the comments at >> http://copiousfreetime.blogspot.com/2006/12/django-su.html <<, if the solution still works, you can mark that someone is SU'd, and provide a mechanism for them to log out through an unrelated view so that the views you're testing and/or debugging remain untouched and as they would appear to the user you are logged in as.

    ReplyDelete
  9. I have just wrapped all of this up into an egg here:

    https://github.com/continuous/django-su

    I have added to ability to exit the su'ed session, and removed the need for an authentication backend. I hope it helps someone :)

    ReplyDelete