Disclaimer: this solution works only if the static content is served under the same domain as the application. This is because we need a cookie with session ID in order to authenticate the user.

A few years ago I was asked to find a solution that would help protect static files against unauthorized users. Because of reasons defining an action in one of the application controllers was not an option, yet I had to make sure that the user is both logged in and authorized to access the file (based on a role). The solution I found and ended up using was called ngx_http_auth_request_module.

As stated in the documentation:

The ngx_http_auth_request_module module (1.5.4+) implements client authorization based on the result of a subrequest. If the subrequest returns a 2xx response code, the access is allowed. If it returns 401 or 403, the access is denied with the corresponding error code. Any other response code returned by the subrequest is considered an error.

Let’s see how we can make use of it.

Setting up the application

To demonstrate the usage of this module, I will use nginx/1.14.0 and PHP 7.2.9 with Symfony Framework on Ubuntu 18.04. Let’s create the website-skeleton project with composer:

composer create-project symfony/website-skeleton /var/www/protectapp

Now we need to adjust security settings in the application, so we can authenticate users. I will provide brief description of what is happening here, but I recommend checking out Symfony Security component documentation: https://symfony.com/doc/current/security/form_login_setup.html.

Okay, first we need to add two new users in config/packages/security.yaml, so the file looks like this:

security:
  providers:
    in_memory:
      memory:
        users:
          normaluser:
            password: s3cr3t
            roles: 'ROLE_USER'
          admin:
            password: 4dm!n
            roles: ['ROLE_ADMIN', 'ROLE_USER']
  encoders:
    Symfony\Component\Security\Core\User\User: plaintext

  firewalls:
    dev:
      pattern: ^/(_(profiler|wdt)|css|images|js)/
      security: false
    main:
      anonymous: true
      logout:
        path:   /logout
        target: /login
      form_login:
        login_path: login
        check_path: login
  access_control:
  - { path: ^/(login|authenticated), roles: IS_AUTHENTICATED_ANONYMOUSLY }
  - { path: ^/, roles: ROLE_USER }

What happened here? We:

  • created two in-memory users: normaluser which is a regular user and admin with additional administrative role,
  • configured Symfony User Model with plaintext encoder (because our password are not encoded in any way),
  • configured the login & logout paths,
  • protected all URLs and allowed anonymous users to access the login page and the authentication checker.

Then we need to add a dummy logout route, that doesn’t point to anything in config/routes.yaml:

logout:
  path: /logout

Now let’s create our security controller with the surprising name SecurityController. Symfony console can help us with it, so in the application directory type:

php bin/console make:controller SecurityController

This will create 2 files:

  • src/Controller/SecurityController.php which is the main controller file that will contain 3 actions:
    • index - page that all authenticated users see,
    • login - page with our login form,
    • authenticated - that will return appropriate HTTP response based on user’s current status.
  • templates/security/index.html.twig which is the view file for our index action.

Our basic index.html.twig could look like this:

{% extends 'base.html.twig' %}
{% block body %}
    Hello {{ app.user }}! (Roles: {{ app.user.roles|join(', ') }})
{% endblock %}

We need additional view file for our login form, let’s create it:

touch templates/security/login.html.twig

and put our code in it:

{% extends 'base.html.twig' %}

{% block body %}
    {% if error %}
        <div>{{ error.messageKey|trans(error.messageData, 'security') }}</div>
    {% endif %}

    <form action="{{ path('login') }}" method="post">
        <label for="username">Username:</label>
        <input type="text" id="username" name="_username" value="{{ last_username }}"/>

        <label for="password">Password:</label>
        <input type="password" id="password" name="_password"/>

        <button type="submit">login</button>
    </form>
{% endblock %}

Now it’s time for the controller. This is the complete code for the controller:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

class SecurityController extends AbstractController
{
    /**
     * @Route("/", name="index")
     */
    public function index(): Response
    {
        return $this->render('security/index.html.twig');
    }

    /**
     * @Route("/login", name="login")
     */
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        $error = $authenticationUtils->getLastAuthenticationError();
        $lastUsername = $authenticationUtils->getLastUsername();

        return $this->render('security/login.html.twig', array(
            'last_username' => $lastUsername,
            'error'         => $error,
        ));
    }

    /**
     * @Route("/authenticated/{role}", name="authenticated")
     */
    public function authenticated(string $role, AuthorizationCheckerInterface $authChecker): Response
    {
        $role = sprintf('ROLE_%s', strtoupper($role));

        if($authChecker->isGranted($role)) {
            return new Response();
        }

        return new Response(null, Response::HTTP_FORBIDDEN);
    }
}
index

This method will just display the view. Since it was previously configured to be protected we don’t need to add any checks in there.

login

In this method we make use of Symfony’s AuthenticationUtils to retrieve login errors and the last username that was provided in the login form. We pass it to the view and display login form.

Since we are using built-in, basic in-memory user authentication, all the magic is done by the Symfony Framework, thus no login/password checks in the method.

authenticated

Here we check if the user is authenticated and if (s)he has the specified role. By default Symfony would redirect unauthenticated users to the login form, but we don’t want that, because nginx module considers only 2xx, 401 and 403 HTTP status codes to be valid.

If the user is authenticated and authorized, we return an empty response with status code 200 (default), if not then 403 Forbidden is served.

Funny cat pictures

We need the files that we actually want to protect, so let’s create 2 directories for static content: one for regular users and one for admins only:

mkdir -p public/content/users
mkdir -p public/content/admins

then download cat images into these directories:

wget -O public/content/users/usercat.jpg http://cf.ltkcdn.net/cats/images/std/63769-180x169-Cat_black_4.jpg
wget -O public/content/admins/admincat.jpg http://www.dailygameshub.com/thumbnails/cat-clicker-mlg-35112.jpg

nginx magic

Now it is time for the magic! In a “normal” website configuration anyone that knows the URL can access both user content and admin content. Our goal is to protect these files and we will do it in the virtual host configuration. Let’s use the default virtual host /etc/nginx/sites-enabled/default:

server {
  server_name localhost;
  root /var/www/protectapp/public;

  location / {
    try_files $uri /index.php$is_args$args;
  }

  location ~ ^/index\.php(/|$) {
    internal;
    fastcgi_pass            unix:/var/run/php/php7.2-fpm.sock;
    fastcgi_split_path_info ^(.+\.php)(/.*)$;
    include                 fastcgi_params;
    fastcgi_param           SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_param           DOCUMENT_ROOT $realpath_root;
  }

  location ~ \.php$ {
    return 404;
  }

  location = /auth-admin {
    internal;
    proxy_pass              http://localhost/authenticated/admin;
    proxy_pass_request_body off;
    proxy_set_header        Content-Length "";
    proxy_set_header        X-Original-URI $request_uri;
  }
  
  location = /auth-user {
    internal;
    proxy_pass              http://localhost/authenticated/user;
    proxy_pass_request_body off;
    proxy_set_header        Content-Length "";
    proxy_set_header        X-Original-URI $request_uri;
  }

  location ~ ^/content/users {
  	auth_request /auth-user;
  }

  location ~ ^/content/admins {
    auth_request /auth-admin;
  }
}

and reload nginx:

service nginx reload

Ta-daa! Now if you try to open http://localhost/content/users/usercat.jpg or http://localhost/content/admins/admincat.jpg in your browser, you should see 403 Forbidden error for both of the images: Forbidden

Then when you log in with login normaluser and password s3cr3t you should be able to see the first image: NormalCat but still be denied access to the admincat picture.

Finally if you login as admin with password 4dm!n you should be able to see both images: AdminCat

That’s it!