Skip to main content

Using Duo for 2FA with Apache

Published

I run several web applications on my domains that I want to protect with authentication but these applications do not provide built-in user authentication, built-in user management, or really have users at all. So like many people I protect these web applications with Apache’s “basic” authentication. If you’re not familiar with “basic” authentication it’s the authentication system where your browser pops up a box and asks for a username and a password and then it passes that information back (in plain text) to the web server in an HTTP header. If you use SSL to protect the information in transit, or you configure digest authentication, it’s relatively secure. But how are you supposed to integrate two-factor authentication into that?

I’ll show you how using six technologies:

  1. Duo Push for implementing 2FA.
  2. Apache web server but only very recent versions. For example, the version of Apache that comes with Debian Buster (2.4.38) does not work but the version that comes with Debian Bullseye (2.4.51) will work.
  3. Apache mod_authnz_external for integrating with the script that we’re gonig to write.
  4. Apache mod_auth_form combined with mod_session, mod_session_cookie and mod_session_crypto to keep track of logins.
  5. Redis for keeping track of when we last authenticated to avoid hitting Duo too often.
  6. Python? I guess? For integrating all of these components together? It counts.

Basically what we’re going to do to accomplish this system is:

  1. When you go to a page that requires authentication we will use mod_authnz_external to delegate the authentication to an external script.
  2. That external script will validate your username and password and if not valid then you will be denied access.
  3. If your username and password are valid then the external script will see if your session is in Redis. If it is in Redis then you will be allowed in without further ado. If it is not then Duo will be contacted and you will be sent a “Duo Push” notification to confirm that you are you.
  4. If you confirm the “Duo Push” notification then your session will be put into Redis with some configurable expiration policy so that future requests will not have to use Duo.

You can see all of the code that I’m going to share here over on GitHub in a repository that I created for this very system.

Catches

There are a couple drawbacks to this solution that you should be aware of.

  1. If you are protecting an application that has lots of static documents that are also being protected then because of the way that mod_authnz_external works you may end up slamming your system and/or getting a lot of Duo notifications. You should whitelist and/or minify static documents to the extent you can such that they do not require authentication. You should also not protect something that has the potential to be hit by lots of random web clients because they’ll all initiate calls to your external program which can be resource consuming even if they do not authenticate successfully.
  2. If you are protecting an application that does web requests in the background (e.g. AJAX polls) then when your session expires and you have to authenticate with Duo again then you may end up locking yourself out of your Duo account if you do not answer the notifications promptly. If you do not answer enough of the notifications – for example, you are sleeping – then your account will become locked and a Duo administrator will need to reactivate your account. If you are the Duo administrator then maybe this is not as big of a deal.
  3. This obviously only works with Duo’s “push” system and not generic 2FA TOTP or HOTP tokens.

Assembling The Pieces

Looking at the repository where I’ve implemented this, let’s get the pieces together. They are:

  1. Configure Duo
  2. Configure Redis
  3. Configure Apache
  4. Configure the authenticator
  5. Create the login form

Configuring Duo

First, you must have a Duo account. In your Duo account you will create an application using what is called “Partner Auth API”. When you’ve configured the application you will have an ikey, an skey, and a hostname. You will also need to create a user in Duo with the same username that you’re going to use for your authentication system. That is, if you will enter “joe” into the form, you should create a user called “joe” on the Duo control panel.

Configuring Redis

There is no required special configuration for Redis. Whatever configuration that you have should work. Just pass that configuration to the authenticator configuration, as described below, and it should work.

Configuring Apache

Assuming you have a working configuration for Apache, let’s add two configurations. The first is global configuration for the login form that uses mod_auth_form.

We will install Apache, the required extras, and enable the necessary Apache modules:

apt-get install --no-install-recommends apache2 apache2-utils libapache2-mod-authnz-external
a2enmod authnz_external auth_form session session_cookie session_crypto request

Then we can add the global Apache configuration:

Session On
SessionCookieName formsession path=/;httponly;secure;SameSite=Lax
SessionCryptoPassphraseFile /etc/private/session-secret.txt

# use custom authenticator that sends through duo
DefineExternalAuth duo pipe "/usr/local/bin/check-duo-wrapper -c /etc/private/auth-configuration.json"

# this has the login form (must appear after the previous location)
<Location "/login">
    Require all granted
</Location>

# this endpoint will log you out by effectively clearing the session cookie
<Location "/logout">
    SetHandler form-logout-handler
    AuthFormLogoutLocation /login
    Require all granted
</Location>

# this is the endpoint where logins are submitted
<Location "/login/submit">
    SetHandler form-login-handler
    AuthFormLoginRequiredLocation /login
    AuthFormLoginSuccessLocation /private

    AuthType form
    AuthFormProvider external
    AuthExternalContext login
    AuthName "formlogin"
    AuthExternal duo
    Require valid-user
</Location>

In this configuration these are these assumptions:

  1. You have SSL configured. (If you do not then (a) your password will be sent over the wire in clear text (b) the cookie configuration will not work.)
  2. Users will be redirected to /login when they come to a page that requires authentication.
  3. Users can go to /logout to clear their session and log out.
  4. After a user logs in they will be redirected to /private.
  5. The configuration files are stored under /etc/private.
  6. The authenticator is stored under /usr/local/bin.

You’re welcome to use different paths if you want to. Nothing here is written in stone.

Finally, any page that you want to protect just needs a block like this:

<Location "/private">
    AuthType form
    AuthFormProvider external
    AuthFormLoginRequiredLocation /login
    AuthName "formlogin"
    AuthExternal duo
    Require valid-user
</Location>

Configuring the Authenticator

There are two configuration files that will contain secret information. You should create these and put them somewhere on your server that is relatively protected and ensure that they are owned by root and readable only by root. DO NOT PUT THESE INTO A CONTAINER. Mount these into a container. These files are the keys to the kingdom. We’re going to put them into /etc/private.

The first configuration file should be called /etc/private/auth-configuration.json and look like this:

{
    "username": "joe",
    "password": "s00p3rSekrit!",
    "duo": {
        "ikey": "Your Application Integration Key",
        "skey": "Your Application Secret Key",
        "host": "api-1234.duosecurity.com"
    },
    "cache": {
        "host": "localhost",
        "port": "6379"
    },
    "session": {
        "name": "formsession",
        "expiry": 14400
    }
}

The above configuration does a few things:

  • Configures the username “joe” with a password.
  • Configures the Duo application credentials.
  • Configures the Redis credentials. These are passed directly to the Python redis library so put whatever works with that in here.
  • Configures some session details. The name must match the cookie that your session uses which is configured in your Apache configuration file. The expiry is how often, in seconds, you must reauthenticate with Duo.

The second configuration file should be called session-secret.txt and it will contain some random text. This file is used by Apache to encrypt the session data and thus encrypt the cookie that gets sent to your browser. If you do not do this then your session cookie will contain your password in clear text. The encrypted session cookie is also the key in Redis that we will use to identify a returning visitor.

Generate this random text by running something like this:

openssl rand -base64 42 > /etc/private/session-secret.txt

Again, place both of these files somewhere on your server and ensure that they are owned by root and readable only by root.

Next we will create a file called /usr/local/bin/check-duo-wrapper that will use sudo to read our configuration file and call out to Duo. It’s a pretty simple shell script:

#!/bin/sh
exec sudo --preserve-env /usr/local/bin/check-duo "$@"

To make sudo work we’ll add a sudoers configuration to /etc/sudoers.d/wwwdata:

www-data ALL=(root:root) NOPASSWD:SETENV:/usr/local/bin/check-duo

Then we’ll actually create the /usr/local/bin/check-duo file by pulling it from GitHub, fixing all of the permissions, and installing its Python dependencies:

apt-get install --no-install-recommends python3-requests python3-redis python3-schema
curl https://raw.githubusercontent.com/paullockaby/apache-external-duo/main/check-duo.py > /usr/local/bin/check-duo
chmod +x /usr/local/bin/check-duo
chmod +x /usr/local/bin/check-duo-wrapper
chmod 700 /etc/private
chmod 600 /etc/private/session-secret.txt
chmod 600 /etc/private/auth-configuration.json

Creating the Login Form

Our final component is the login form. You’ll want to create this in an HTML file that will be served at /login. This might mean creating a directory called /var/www/html/login and then creating /var/www/html/login/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Login Required</title>
  </head>
  <body>

    <form method="POST" action="/login/submit">
      <h1>Login Required</h1>

      <div>
        <input type="text" spellcheck="false" autocapitalize="off" autocorrect="off" id="floatingInput" placeholder="Username" name="httpd_username">
        <label for="floatingInput">Username</label>
      </div>
      <div>
        <input type="password" id="floatingPassword" placeholder="Password" name="httpd_password">
        <label for="floatingPassword">Password</label>
      </div>

      <button type="submit">Sign in</button>
    </form>

  </body>
</html>

And You Are Done

Now you should have these pieces in place:

  1. All Apache modules and Apache configurations.
  2. A Python script to reach out to Duo, a wrapper that calls that script with sudo, and a sudo configuration that lets the www-data user call the script.
  3. Two configuration files for Apache and the authenticator.
  4. A basic login form.

If you restart Apache and try going to /private then it will redirect you to /login where you can enter the configured username and password from your configuration file and after confirming the login via Duo you will be redirected back to /private.