Using Nginx + Ruby to serve and protect private web services

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:

1 - When accessing a protected URL, users are asked to authenticate themselves
2 - Authentication is done using Google accounts (other options can be added)
3 - Users are served all the awesomeness of the internal service
4 - Or not
5 - Finally a simple dashboard allows users to do a single sign-off

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.

  1. I genereated the wildcard SSL certificate using these instructions

  • programming
  • nginx
  • web
  • ruby