Menu

Windows Authentication on External Websites

My home network is domain-based, and I’m running a Windows Server 2008 VM as the domain controller. I’ve written in the past about how to use PHP to do authentication using domain credentials, and that works great for some scenarios. As a case in point, I use Pydio to host a web-based file manager that allows me access to my files when I’m out and about. Pydio runs on a linux server VM on my home server, and it actually includes a built-in mechanism to authenticate against an LDAP server (the Windows domain controller) so I didn’t have to modify it with my PHP code. The principle is the same, though.

image

This is all good stuff for anything hosted on my home server, but what if that isn’t what I want? What if I want to host something on my external, public webserver, and still use my active directory credentials to sign in to it? And, while we’re at it, what if I want to be even more restrictive and limit access to a particular organizational unit within active directory?

As luck would have it, these are all problems that I solved this week. Read on!

Creating a Reverse SSH Tunnel

The first thing we need is to establish a secure connection between the external webserver (the VPS) and the internal webserver (the local linux VM). We’re going to use a reverse SSH tunnel to do this, and, specifically, we’re going to use a tool called autossh that will keep an eye on the tunnel and restart it if something goes wrong.

I’ll skip most of the technical detail here, but essentially a reverse tunnel is going to forward a particular port on the external server to a particular port on the internal server. It’s called a reverse tunnel because it’s the internal server that triggers the connection. That’s important: the internal server can reach the external one just fine, but not the other way around (thanks to things like me having a dynamic IP address for my home internet connection, my home router’s firewall, DHCP, etc).

I installed autossh on my Ubuntu server VM:

sudo apt-get install autossh

And then wrote a one-line script that I placed in /etc/network/if-up.d:

#!/bin/sh
su -c "autossh -M 29001 -f -N -R 8080:localhost:80 remote-server.com" localadmin

Teasing this apart just a little, it uses the local account “localadmin” to run the command enclosed in the quotation marks. That command forwards port 8080 on “remote-server.com” to port 80 on the local machine.

For this to work it’s essential that the user “localadmin” is able to log on to “remove-server.com” without needing to enter a password.

The Local Server

The local webserver is where most of the heavy-lifting is going to take place. The first thing I did was create a new virtual host in the webserver configuration on that machine. I’m using lighttpd, so my configuration looks like this:

$HTTP["host"] =~ "^auth\.gateway" {
    $HTTP["remoteip"] !~ "127.0.0.1" {
        url.access-deny = ("")
    }

    server.document-root = "/home/jason/WebServer/Production/auth.gateway"

    url.rewrite-once = (
        "^(.*)$" =>"auth.php"
    )
}

Essentially it creates a new host with the hostname “auth.gateway” that’s only accessible to the local machine (127.0.0.1). Any request that comes in regardless of the URI is rewritten to a file called auth.php in the document root.

The hostname here isn’t real (i.e. there’s no public DNS entry for it), and that’s probably a good thing for the sake of locking down security as tightly as possible. Also on that line of thinking is the fact that access is limited to the local machine. The webserver doesn’t know that requests coming through our SSH tunnel are coming from another machine thanks to the virtues of port forwarding – it thinks they’re coming from other processes running locally (i.e. coming from 127.0.0.1).

Next is auth.php itself, the engine that runs all this stuff:

<?php
    session_name ('AGSESSID');
    session_set_cookie_params(600);
    session_start();

    $domain = "testdomain.local";
    $domaincontroller = "dc1.testdomain.local";
    $requiredou = "FamilyMembers";
    $authkey = "big-random-string";

    if (!isset($_SERVER['PHP_AUTH_USER']) || !isset($_SERVER['HTTP_X_AUTH_KEY']) || $_SERVER['HTTP_X_AUTH_KEY'] != 'big-random-string') {
        header('WWW-Authenticate: Basic realm="Authentication Required"');
        header('HTTP/1.0 401 Unauthorized');
        exit;
    } elseif (!isset($_SESSION['AuthKey']) || $_SESSION['AuthKey'] != md5($_SERVER['HTTP_X_FORWARDED_FOR'].$_SERVER['PHP_AUTH_USER'].$_SERVER['PHP_AUTH_PW'])) {
        $ldap = ldap_connect($domaincontroller);

        ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
        ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);

        if (!$bind = ldap_bind($ldap, $_SERVER['PHP_AUTH_USER']."@".$domain, $_SERVER['PHP_AUTH_PW'])) {
            header('WWW-Authenticate: Basic realm="Authentication Required"');
            header('HTTP/1.0 401 Unauthorized');
            exit;
        } else {
            $result = ldap_search($ldap, "DC=".implode(",DC=", explode(".", $domain)),  "(samaccountname=".$_SERVER['PHP_AUTH_USER'].")");
            $entries = ldap_get_entries($ldap, $result);
 
            if (strpos($entries[0]['distinguishedname'][0], "OU=".$requiredou) === FALSE) {
                header('WWW-Authenticate: Basic realm="Authentication Required"');
                header('HTTP/1.0 401 Unauthorized');
                exit;
            } else {
                $_SESSION['AuthKey'] = md5($_SERVER['HTTP_X_FORWARDED_FOR'].$_SERVER['PHP_AUTH_USER'].$_SERVER['PHP_AUTH_PW']);
            }
        }
    }
?>

As you can see, this is quite a bit more sophisticated than the previous example in my SSO post, but let’s tease this one apart at a high level too.

When the script is loaded, it first checks to see if the there’s a username, password and “AUTH_KEY” contained within the HTTP headers of the request. If so, it verifies that the “AUTH_KEY” is what it was expecting, and it tries to use the username and password to establish an LDAP connection to the Windows server VM. If that’s successful, it retrieves some information about the user and checks if they’re a member of the required organizational unit.

If all that works it sends back an empty page, but, crucially a HTTP 200 status (meaning everything is OK). If any of those checks fail then it sends back a HTTP 401 status (unauthorized) header instead.

In addition to this, the script creates a PHP session with a custom name. The name is custom to avoid collisions with any session that the external server may be creating, but essentially the session lasts 10 minutes, and if subsequent requests come in for a user that’s already been authorized then it skips all the checks and just sends back the HTTP 200 header. It does that because without it, every HTTP request made to the remote server (not just every page served, but every image on every page, every CSS file, JavaScript file, etc, etc) would involve a query to the domain controller to validate the credentials, and that’s a potential bottleneck with performance implications.

The Remote Server

My remote server is running nginx as its webserver (I’m new to it, but I think I like it better than lighttpd. That’s kind of beside the point though). The configuration looks like this:

server {
    server_name example.com;
    listen 80;

    root /home/jason/example.com/www;

    auth_request /__auth;
    auth_request_set $saved_set_cookie $upstream_http_set_cookie;
    add_header Set-Cookie $saved_set_cookie;

    location = /__auth {
        auth_request off;

        proxy_pass http://localhost:8080;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header Host auth.gateway;
        proxy_set_header X-Original-URI $request-uri;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Auth-Key "big-random-string";
    }
}

When we tease this one apart, there are two key details. One is that “auth_request” declaration in the fourth line. As per nginx’s documentation, auth request “implements client authorization based on the result of a subrequest.” In other words, instead of nginx doing the authentication itself it forwards the request on. The configuration above defers the authentication of http://example.com to http://example.com/__auth.

The second crucial chunk of the configuration file is everything within the “location = /__auth” declaration. The first thing this does is turn off the “auth_request” functionality (otherwise, we’re stuck in an endless loop), and it then creates a proxy to redirect any requests to http://example.com/__auth to http://localhost:8080. Port 8080, if you recall, is in turn forwarded through our SSH tunnel to port 80 on the local server.

Additionally, it sets the hostname involved in the request to “auth.gateway,” forwards a couple of other pieces of information as headers, and, for some added security, sends an “X-Auth-Key” header that our PHP script checks for.

Back outside of this block the “auth_request_set” declaration takes the session cookie from our PHP script and saves it to a variable, then the following line (“add_header”) sends that cookie back to the client’s browser as part of the response they receive.

Done!

Real World Problems with All This

I mentioned earlier that we set a session cookie as part of the whole interaction to avoid the need to query the LDAP server for every HTTP request the remote server receives, and I said this was to avoid a performance bottleneck. That’s true, but we still have a performance bottleneck here: for every HTTP request the remote server receives it’s still querying the local server through our SSH tunnel, even if all the local server is doing is responding that the credentials are good based on the existence of the session cookie.

This communication has to take place over my home internet connection, which is absolutely not what my ISP intended it to be used for. I don’t think it’s against their terms and conditions or anything like that, it’s just that it isn’t really fast enough.

If the site deployed on the remote server was one of my own making then I’d modify this approach to create an authentication API of sorts on the local server, and I’d do the session setting and credential caching entirely on the remote server, drastically reducing the number of queries to the local server (all the way down to a single query, in fact, when the session is first created and the user logs on).

The other problem is one of securing the whole interaction. We’re using “basic” HTTP authentication methods here, which means that the username and password are passed around in the clear (they’re not encrypted or hashed as part of the process). That’s necessary: the auth.php script has to receive the password in cleartext because it has to pass it to the Windows server to check its validity. It’s also not an issue with the communication between the remote and local webservers, because that happens through an SSH tunnel that’s using public/private keypairs to provide encryption. It is a problem for the communication between the user and the remote webserver though, and it leaves our user vulnerable in several ways (especially if they’re using a public WiFI hotspot). Essentially what I’m getting it is that you must use SSL between the user and the remote server.

Conclusion

This is not the most optimal way of doing things, particularly in regards to the bottleneck it creates by deferring authentication of every HTTP request to my local server which is connected to the internet using a typical consumer-grade ADSL connection, but as a quick and dirty way of securing resources on my public webserver without needing to modify the resource itself in any way, it works great!

Enjoy!

Comments & Discussion