Adding security features to Django projects

Security is most valuable feature of any software, and each developer should keep in mind security issues during programming. In this article I show how to restrict user's access to view, but not modify objects in Django project. It could be an equivalent of 'Readers' field in Lotus Notes/Domino application.

First of all, let's set up Django.

Check out Django’s main development branch (the ‘trunk’) like so:

svn co http://code.djangoproject.com/svn/django/trunk/ django-trunk

Install it:

cd django-trunk
sudo python setup.py install

Create project, which be called 'secure_site':

django-admin.py startproject secure_site

Test the installation - start our project:

cd secure_site/
chmod +x manage.py
./manage.py runserver 9000

Open browser by URL: http://localhost:9000/ and if 'It worked!' page is shown, then go further.

Create two applications - sample (for testing) and secure (for handling security information):

./manage.py startapp sample
./manage.py startapp secure

Now change settings.py file to use SQLite database:

DATABASE_ENGINE = 'sqlite3'
DATABASE_NAME = 'secure_site.db'

Also activate admin interface via adding 'django.contrib.admin' to the end of INSTALLED_APPS in settings.py and uncomment lines in urls.py:

from django.conf.urls.defaults import *

# Uncomment the next two lines to enable the admin:                             
from django.contrib import admin
admin.autodiscover()

urlpatterns = patterns('',
    # Example:                                                                  
    # (r'^secure_site/', include('secure_site.foo.urls')),                      

    # Uncomment the next line to enable admin documentation:                    
    (r'^admin/doc/', include('django.contrib.admindocs.urls')),

    # Uncomment the next line for to enable the admin:                          
    (r'^admin/(.*)', admin.site.root),
)

Now it is time to create database and superuser account:

./manage.py syncdb

Open browser by URL: http://localhost:9000/admin/ and if 'Login' page is shown, try to login with login/password created during syncdb and if everything is fine then go further.

Now it is time to create sample models. Open sample/models.py with your favourite editor and add two models:

""" Sample models. """
from django.db import models

class Model1(models.Model):
    """ Sample model 1. """
    name = models.CharField(max_length = '100')

    def __unicode__(self):
        """ String representation of this model. """
        return self.name

class Model2(models.Model):
    """ Sample model 2. """
    name = models.CharField(max_length = '100')

    def __unicode__(self):
        """ String representation of this model. """
        return self.name

Also it is required to register these model with admin site. Just create sample/admin.py file and add these lines:

from django.contrib import admin
from secure_site.sample.models import Model1, Model2

admin.site.register(Model1)
admin.site.register(Model2)

After that register models in settings.py (add 'secure_site.sample' to INSTALLED_APPS) and synchronize database.

Run server and open browser by URL: http://localhost:9000/admin/ and if you see links to models 1 and 2 after logging in then go further. Now we have to create model for securing our application. This model will contains application and model names and users' names who do not have access to modify objects. Open secure/models.py and edit it with favourite editor:

""" Models for securing Django project. """
from django.db import models
from django.conf import settings
from django.contrib.auth.models import User
import re

def get_apps_list():
    """ Get list of available applications. """
    app_regex = re.compile('secure_site\.(\w+)')
    apps = [re.search(app_regex, app).group(1)
            for app in settings.INSTALLED_APPS
            if app.startswith('secure_site.')]
    result = []
    for app in apps:
        try:
            __import__('%s.models' % app, fromlist = [app])
            result.append((app, app))
        except ImportError:
            pass
    return result

class SecuredModule(models.Model):
    """ Model for module. """
    app = models.CharField(max_length = '100', choices = get_apps_list())
    model = models.CharField(max_length = '100', choices = [('', '')])
    readers = models.ManyToManyField(User, blank = True)

    def __unicode__(self):
        return '%s.%s' % (self.app.lower(), self.model.lower())

Register model with admin site (secure/admin.py):

from django.contrib import admin
from secure_site.secure.models import SecuredModule

admin.site.register(SecuredModule)

After that register models in settings.py (add 'secure_site.secure' to INSTALLED_APPS) and synchronize database.

Now you can see new application on the admin page - Secure. If you will try to add new module you will see all available application (in our case - secure and sample) and all registered users. But there is an issue here - the list of choices for models is empty. Now we will fix it using AJAX with jQuery. First of all, you have to download jQuery from official site and put it to you application. For this we will create media dir and register it in the project:

mkdir -p media.site/js
cd media.site/js
wget http://jqueryjs.googlecode.com/files/jquery-1.2.6.min.js
ln -s jquery-1.2.6.min.js jquery.js
cd -

Now it is required to set up MEDIA_ROOT (settings.py)

import os
CURRENT_PATH = os.path.dirname(__file__)
MEDIA_ROOT = os.path.join(CURRENT_PATH,  'media.site')

And fix urls.py to view media files (add this line to urlpatterns):

    (r'^media.site/(?P.*)$', 'django.views.static.serve',
        {'document_root': settings.MEDIA_ROOT}),

Now it is time to modify admin's change_form template for SecuredModule model and include jQuery script. For this we create templates dir, register it and modify required template.

mkdir templates

Modify settins.py (CURRENT_PATH was declared earlier during setting up MEDIA_ROOT):

TEMPLATE_DIRS = (
    os.path.join(CURRENT_PATH, 'templates'),
)

Prepare template for change_form:

mkdir -p templates/admin/secure/securedmodule
touch templates/admin/secure/securedmodule/change_form.html

Now let's modify change_form.html:

{% extends "admin/change_form.html" %}
{% block extrahead %}
{{ block.super }}
<script type="text/javascript" src="/media.site/jquery.js"></script>
<script type="text/javascript">
  $(document).ready(function() {
      var app = $('select#id_app');
          var reload_func = function() {
                  $.getJSON('/get_models/', {app : app.val()}, function(j) {
                  var options = '';
                  var model = '';
                      $.each(j, function(i, item) {
                           if (item.model) {
                               model = item.model;
                           }
                           if (item.value) {
                               options += '';
                           }
                      });
                      $('select#id_model').html(options);
                  });
          };
          reload_func();
          app.change(reload_func);
      });
</script>
{% endblock %}

In code above we call '/get_models/' request handler via AJAX-request and modify id_model options. Let's add get_models view (change secure/views.py):

""" Views for working with secure application. """

import re
from secure_site.secure.models import SecuredModule
from django.http import HttpResponse, Http404
from django.utils import simplejson
import logging
from django.db import models

class JsonResponse(HttpResponse):
    """ Class representing JSON response. """
    def __init__(self, data):
        HttpResponse.__init__(self, content = simplejson.dumps(data),
                              mimetype = 'application/json')

MODULE_ID_REGEX = re.compile('/admin/secure/securedmodule/(\d+)/?$')

def get_app_module(app):
    """ Get application module.
    'app' - application name."""
    try:
        return __import__('%s.models' % app, fromlist = [app])
    except ImportError, error:
        logging.debug(error)
        raise Http404

def get_module_classes(app):
    """ Get module classes.
    'app' - application name."""
    module_values = get_app_module(app).__dict__.values()
    return [val for val in module_values
 if isinstance(val, type) and issubclass(val, models.Model)]

def get_models(request):
    """ Get all available models.
    'request' - request instance."""
    app = request.GET.get('app', '')
    reply = []
    matches = MODULE_ID_REGEX.search(request.META['HTTP_REFERER'])
    if matches: # module is existed
        id = matches.group(1)
        module = SecuredModule.objects.get(id = id)
        reply.append({'model' : module.model})
    if app:
        classes = get_module_classes(app)
        reply.extend([{ 'value': class_obj.__name__}
                      for class_obj in classes]);
        SecuredModule._meta.get_field('model')._choices.extend(
            [(class_obj.__name__, class_obj.__name__)
             for class_obj in classes])
    return JsonResponse(reply)

We defined JsonResponse class for working with AJAX and added get_models view. Register it in urls.py:

from django.conf.urls.defaults import *
from django.contrib import admin
from django.conf import settings
from secure_site.secure.views import get_models

admin.autodiscover()
urlpatterns = patterns('',
    (r'^get_models/?$', get_models),
    (r'^media.site/(?P.*)$', 'django.views.static.serve',
        {'document_root': settings.MEDIA_ROOT}),
    (r'^admin/doc/', include('django.contrib.admindocs.urls')),
    (r'^admin/(.*)', admin.site.root),
)

Now everything is ready to dynamically refresh model's field in form. After starting project with runserver do this:

  • - add new user 'test' with Staff and Superuser status;
  • - add new module for model1 and test user as reader;
  • - add few model1 objects.

And the last part - checking user's access. The one of ways is using middleware. It is not best way (we can't restrict to change model via Django API), but acceptable enough for the demo. Let's create middleware class:

mkdir middleware
touch middleware/secure.py
touch middleware/__init__.py

Add 'secure_site.middleware.secure.CheckAccess' to the end of MIDDLEWARE_CLASSES list in settings.py. And change middleware/secure.py:

import re
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpResponseForbidden
from secure_site.secure.models import SecuredModule

APP_MODEL_REGEX = re.compile('^/admin/(\w+)/(\w+)/\d+/?$')

def find_module(app, model):
    """ Find module object using application and model names.
    'app' - application name.
    'model' - model name."""
    module_name = '%s.%s' % (app, model)
    for module in SecuredModule.objects.all():
        if module_name in module.__unicode__():
            return module
    return None

class CheckAccess(object):
      """Middleware that gets various objects from the
      request object and saves them in thread local storage."""
      def process_request(self, request):
          matches = APP_MODEL_REGEX.search(request.META['PATH_INFO'])
   if matches:
              app = matches.group(1)
       model = matches.group(2)
              module = find_module(app, model)
              if module:
                  try:
                      module.readers.get(username = unicode(getattr(request, 'user', None)))
                      return HttpResponseForbidden('

Permission denied!

') except ObjectDoesNotExist: pass
That's all! Now let's test it: login with test user and try to open any of model1 objects created earlier. If you got 'Permission denied!' message, then everything is fine - security features are working!

DOWNLOAD - secure_site.tar.gz (20.58KB)

Comments

Popular posts from this blog

Web application framework comparison by memory consumption

Trac Ticket Workflow

Python vs JS vs PHP for embedded systems