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.
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!