How to Serve Protected Content With Django (Without Bogging Down Your Application Server)

Using Nginx's X-Accel-Redirect you can apply permissions to files served directly by Nginx or combine Django and WordPress in the same URL paths.

Like Apache’s mod_xsendfile, Nginx’s X-Accel module provides for internal redirects. An x-accel-redirct is internal because instead of redirecting the client’s request to another URL, it redirects the location of Nginx’s request to another resource.

This is an extremely useful feature because it lets you combine your application’s logic with Nginx’s excellent file handling without sacrificing one for the other.

Note: while this post is directly concerned with using x-accel-redirects with Django, nothing about this feature or these patterns should be construted as Django or Python specific. The lessons here are applicable to any framework on language.

Serving protected files

This is probably the most useful scenario. Let’s say you have files you want served to users but they need to be authenticated, and moreover you want to check fi those users have the permission to access these files.

We’ll take this model definition as the base of our example, with the understanding the functions referenced below, slugify and get_extension, are imported from Django’s default filter library and previously defined, respectively.

class Document(models.Model):
    title = models.CharField(max_length=100)
    file = models.FileField()

    @property
    def pretty_name(self):
        return "{0}.{1}".format(slugify(self.title),
                get_extension(self.file.name))

Now the goal is to write a view that will only serve this document to authenticated users. Because we’re using Django’s authentication system, contrib.auth, we’ll just use the login_required decorator. Clearly in your own app you’ll likely have app specific permission logic. Simply redirecting to the file URL isn’t acceptable because that means the file URL, obscure as it may be, does not require authentication. So serving the file must be controlled by the view.

@login_required
def document_view(request, document_id):
    document = Document.objects.get(id=document_id)
    response = HttpResponse()
    response.content = document.file.read()
    response["Content-Disposition"] = "attachment; filename={0}".format(
            document.pretty_name)
    return response

Well this view certainly does that, but boy is it horrible. The application is opening and reading Bob’s 24MB PowerPoint deck just to make sure the user is logged in. This is a terrible use of resources.

It’d be much better to serve this document with Nginx because Nginx is really fast at serving files. So we’ll use it. Except we can’t point Nginx directly at the file to serve it as if it were a file in the web root directory. So let’s dive right in and use the redirect.

First, here’s an updated location in the Nginx site configuration:

location /protected/ {
  internal;
  alias   /usr/local/documents;
}

Now with this location defined, let’s take another crack at the view function:

@login_required
def document_view(request, document_id):
    document = Document.objects.get(id=document_id)
    response = HttpResponse()
    response["Content-Disposition"] = "attachment; filename={0}".format(
            document.pretty_name)
    response['X-Accel-Redirect'] = "/protected/{0}".format(document.file.name)
    return response

The first thing you should notice about the HttpResponse in the second view is that it doesn’t have any content. All we’ve done is set two headers, one to indicate that the response is an attachment with a name, and the second which is the x-accel-redirect location. When Nginx detects the x-accel-redirect header it will redirect the response before forwarding it to the client.

This lets your application do its thing, whether that’s checking permissions, updating a download count, or emailing you to let you know which students actually downloaded the homework assignment, and then hand off the job of actually serving the file to Nginx.

This is useful in scenarios where you want to guard files and others where you want to disguise the file name/path.

The Django application does it’s auth and permission checks like in the first example, but now instead of responding with the file, it sends an HTTP response with the x-accel-redreict ehader and the path to the file. This is an internal redirect. The user’s URL never changes, and Nginx serves the file from the internally protected directory.

Serving protected upstream services

This feature to serve protected files is extremely useful, but it’s not the end of the road. In the intro I mentioned that we can use this to have Nginx redirect responses to different resources, and that’s exactly what we’re going to do here.

Typically when you have multiple types of sites, like a marketing site, a blog, and an application, you provide each with its own domain regardless of what servers they’re running on. This then has some obvious downsides for URLs. You need app.mydomain.com and blog.mydomain.com, and so on.

I know how to solve that, you say, you just assign specific logical paths in the site configuration.

Not only may that be a royal pain-in-the-ass, it may not be possible without assigning one application’s root domain to some non-root path. If you’re using your blogging platform for static pages, too, does every URL now start with a shared prefix like /blog/ or /pages/?

So what I mean is deploying using the same root path.

Maybe you want to one page for non-logged in users, a public facing site managed by the marketing team, and then access to an application dashboard and data accessible to users and a different team.

Start with an app with a dashboard, and add a WordPress site into the mix. The WordPress site is managed by the marketing team and is used to provide information about the company as well as a product blog. The transition from system to system should look seamless to users.

Just use an internal redirect to reach another backend.

def dashboard(request):
    if request.user.is_authenticated():
        return render(request, "dashboard.html")
    response = HttpResponse()
    response['X-Accel-Redirect'] = '/wordpress/'
    return response

If the user is authenticated then the dashboard page is rendered normally, otherwise the response is an internal redirect to the WordPress site.

Here’s a simple example of a full Nginx site configuration to accommodate this strategy:

upstream example {
  server localhost:5000;
}

upstream php {
  server localhost:9000;
}

server {
  listen 80;
  client_max_body_size 4G;
  server_name www.example.com

  root /var/www/wordpress;
  index index.php;

  access_log  /var/log/nginx/example.access.log;
  error_log /var/log/nginx/example.error.log;

  keepalive_timeout 5;

  # We know this URL, so ensure that it's forwarded
  location /wp-admin {
    try_files $uri $uri/ /wp-admin/index.php;
  }

  location /wordpress/ {
    internal;
    try_files $uri $uri/ /index.php;
  }

  location / {
    try_files $uri @example;
  }

  location ~ \.php$ {
    #NOTE: You should have "cgi.fix_pathinfo = 0;" in php.ini
    include fastcgi.conf;
    fastcgi_intercept_errors on;
    fastcgi_pass php;
  }

  location @php {
    include fastcgi.conf;
    fastcgi_intercept_errors on;
    fastcgi_pass php;
  }

  location @example {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $http_host;
    proxy_redirect off;
    proxy_pass http://example;
  }
}

All things being equal I’d prefer separate domains myself. It’s just that much easier to manage these services when they’re wholly decoupled. However sometimes other concerns must rule the day.

Dynamic X-Accel-Redirect routing

Reconsidering the example with our combined Django app and WordPress site, we did specify two paths for the WordPress admin: the index page and the admin interface, /wp-admin. So what about access to the additional pages, like an about page?

While I’d recommend trying to specify known primary paths in your Nginx configuration, it’s simple to redirect to additional WordPress pages using a custom 404 handler. This will redirect missing requests to the WordPress site, like /about/, and rely on WordPress to respond to truly missing pages.

def my_404_handler(request):
    response = HttpResponse()
    response['X-Accel-Redirect'] = '/wordpress/'
    return response

Presuming ‘wordpress’ is configured as an internal location, any non-match will then get handled by the WordPress site.

blog comments powered by Disqus