Intro
I have a few web applications hosted on my private network which need to be securely accessible by friends and coworkers. Asking everybody to set-up a VPN on their computers/phones is too much trouble. Therefore, I decided to run Nginx as a reverse proxy to serve each internal service on its own public subdomain and to protect its access with a simple authentication service written in Ruby. Users only need to sign-on once to access all subdomains and the backend conveniently authenticates them with their Google account. I was pleased by how simple this was to set up so I decided to blog the good news.
User experience
Below are screenshots of the authentication process for the user:
The great thing is that once logged in, users will directly get the content without seeing any of the pages above! Compared to setting up a VPN, this is a dream!
Nginx and X-Accel-Redirect
The secret sauce I'm using is the X-Accel-Redirect directive of Nginx. It is simple yet powerful: whenever a backend returns the HTTP header "X-Accel-Redirect", Nginx will internally serve the specified location. This way, a very simple HTTP backend can perform authentication and redirection logic while Nginx does the heavy lifting of efficiently serving the content. This works especially well for serving big files.
Nginx configuration
Here is a minimal Nginx configuration to get you going:
# Wildcard SSL certificates;
ssl_certificate /etc/ssl/host.pem;
ssl_certificate_key /etc/ssl/host.key;
# Redirect HTTP to HTTPS;
server {
server_name _;
return 301 https://$host$request_uri;
}
# The PROXY;
server {
# HTTP authentication backend;
set $auth_backend "http://10.10.1.176:4567";
listen 443 ssl;
server_name _;
# Proxy all the request to the backend;
location / {
proxy_cache off;
proxy_pass $auth_backend;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# The backend redirects the request to /reproxy;
location /reproxy {
# make this location internal-use only;
internal;
# set DNS;
resolver 10.0.3.1;
# set $reproxy variable to the value of X-Reproxy-URL header;
set $reproxy $upstream_http_x_reproxy_url;
# proxy request to the received X-Reproxy-URL value;
proxy_pass $reproxy;
}
}
And that's it! Just generate your wildcard SSL certificate1, set up the above virtual host and Nginx is ready to go.
Backend service
I really like the simplicity of the above Nginx configuration. All our system requires is an HTTP backend that returns the following headers when:
Authorized
HTTP/1.1 200 OK
X-Accel-Redirect: /reproxy
X-Reproxy-URL: http://very-private-url.vpn/
...
Forbidden
HTTP/1.1 403 Forbidden
...
This way you can write a simple (or complex) backend in your language of choice.
Example Ruby backend
Below is a Ruby program that performs single sign-on of users using their Google account. The great thing about it is that it comes under 100 lines of code! Note that the complete working solution is available on my github.
require "sinatra/base"
require "omniauth-openid"
require "sinatra/config_file"
class Auth < Sinatra::Base
register Sinatra::ConfigFile
config_file 'config.yml'
# Use a wildcard cookie to achieve single sign-on for all subdomains
use Rack::Session::Cookie, :secret => settings.cookie["secret"],
:domain => settings.cookie["domain"]
# Perform authentication against Google OpenID endpoint
use OmniAuth::Builder do
provider :open_id, :name => 'google', :identifier => 'https://www.google.com/accounts/o8/id'
end
# Catch all requests
get '*' do
pass if request.host == settings.auth_domain
# Authenticate user
unless authenticated?
redirect "https://" + settings.auth_domain + "/?origin=" + request.url
end
# If authorized, serve request
if url = authorized?(request.host)
headers "X-Reproxy-URL" => url + request.fullpath
headers "X-Accel-Redirect" => "/reproxy"
return ""
else
status 403
erb :forbidden
end
end
# Block that is called back when authentication is successful
process = lambda do
auth = request.env['omniauth.auth']
session[:logged] = true
session[:provider] = auth.provider
session[:uid] = auth.uid
session[:name] = auth.info.name
session[:email] = auth.info.email
redirect request.env['omniauth.origin'] || "/"
end
get '/auth/:name/callback', &process
post '/auth/:name/callback', &process
get '/logout' do
session.clear
redirect "/"
end
get '/' do
@origin = params[:origin]
@authenticated = authenticated?
erb :login
end
def authenticated?
if session[:logged]
return true
else
return false
end
end
# Return internal URL or false if unauthorized
def authorized?(host)
# Check whether the email address is authorized
if settings.allowed_email.include?(session[:email]) & settings.routing.key?(host)
return settings.routing[host]
else
return false
end
end
end
Conclusion
This solution is working great so far. It is simple and I have total control over it. When it comes to computers, I can't really ask for a better scenario.
I genereated the wildcard SSL certificate using these instructions ↩