Originally published: 16 September, 2008
[apologies for the “broken” quotes in the code samples in this post: WordPress decided to helpfully fix them for me and now they don’t work – copy-paste with care!]
This article is about using SSL certificates installed into a web browser to authenticate against a Ruby on Rails application. It assumes a reasonable amount of experience of the Apache web server and Ruby on Rails, and a basic understanding of the concepts split-key cryptography. My examples are all done on a Debian Linux Bash shell; your experience may vary.
For the purposes of this article, I used Apache 2 server with a running HTTPS site (although you can set your own up using a self-signed certificate very easily) running my Rails application using the fabulous Phusion Passenger (a.k.a. mod_rails) plugin. You will also need a means of generating and signing SSL certificates as a certification authority – I used CA.pl, which is bundled with OpenSSL, so if you’re on a Linux system and you’ve got Apache serving over HTTPS, you’ve probably got this already.
I’ve tested my finished solution in Internet Explorer 7, Firefox 3, Opera 9.27, Safari 3.1, and Google Chrome 0.2, and only had any trouble with Chrome (it doesn’t seem to correctly support client-side certificates, yet).
1. Creating Certificates
With thanks to the authors of Creating Your Own CA With OpenSSL and especially Client Authentication with SSL.
First, we’ll make ourselves a Certificate Authority so we can sign the browser certificates we make. We’ll be configuring our web server to reject certificates that we didn’t sign, so that we’re completely in control of the process of issuing the certificates to our users. First up, we’ll make a directory, “ssl”, inside our Rails application directory, in which to keep everything we need.
$ cd ~/rails_app $ ls README Rakefile app config db doc lib log public script test tmp vendor $ mkdir ssl $ cd ssl
Next, I’ll find my copies of CA.pl and openssl.cnf and copy them to this directory:
$ locate CA.pl /usr/lib/ssl/misc/CA.pl /usr/share/doc/openssl/doc/apps/CA.pl.pod.gz /usr/share/man/man1/CA.pl.1ssl.gz $ cp /usr/lib/ssl/misc/CA.pl ./ $ locate openssl.cnf /etc/ssl/openssl.cnf /usr/lib/ssl/openssl.cnf $ cp /etc/ssl/openssl.cnf ./
Now I’m ready to create my Certificate Authority:
$ perl CA.pl -newca
Answer the questions to provide the details of your new CA, including the password you will use when signing certificates (don’t forget it!). A directory will be created, “demoCA” (don’t rename it – running this script later will automatically look for it here), which contains – among other things – your CA’s certificate, “cacert.pem”.
Now I’ll make a new certificate for one of the site’s users. Incidentally, you could also use this process to make a certificate for your web server, too, if it doesn’t already have one: just remember to set the “common name” to the domain name of your webserver.
$ perl CA.pl -newreq
Answer the questions about the identity of the certificate. You’ll also be asked to choose a password for this new certificate – if you don’t want one (if you’re making a web server certificate, you probably don’t), you can remove this by running:
$ openssl rsa -in newkey.pem -out newkey.pem
You’ll be asked for the password once more, and the file will be re-written without the password-protection. Next, we’ll sign the new certificate using the Certificate Authority we already created.
$ perl CA.pl -sign
You now have three files:
- newcert.pem – your new certificate, signed by your new certificate authority
- newkey.pem – the private key associated with the new certificate
- newreq.pem – the certificate signing request (CSR) that was used by the certificate authority to sign the certificate
Now we need to package the certificate and key up together into a PKCS#12 file: a single file containing both the certificate and the key and protected by a password, that can be installed into a web browser.
$ openssl pkcs12 -export -in newcert.pem -inkey newkey.pem -out browser_certificate.p12
You will be asked to enter a password to protect the PKCS#12 file, twice.
I’d recommend naming the output file in a way that identifies for whom or where the certificate is to be installed: for example, “daves_cert.p12”.
2. Installing Certificates
No we’ll download the new PKCS#12 certificate you’ve created (e.g. daves_cert.p12) and install it into our web browser. Instructions vary from browser to browser, but this should get you started:
Internet Explorer 7: Tools > Internet Options > Content > Certificates > Import… Select “Personal Information Exchange (*.pfx, *.p12) as the File Type, and browse to the file. You will be asked for the password, and options are available to use “strong private key protection” (asking for the password on every use of the certificate), “marking the key as exportable” (allowing you to re-create the PKCS#12 file from the certificate store at a later date), and to choose which certificate store in which the certificate is placed.
Firefox 3: Tools > Options… > Advanced > Encryption > View Certificates > Import… Navigate to and select the file and, when prompted, enter the password with which is was protected.
Opera 9.23: Tools > Preferences… > Advanced > Security > Manage certificates… > Import… Select “PKCS#12 (with private key) (*.p12)” as the File Type, and browse to the file. You will be asked for the password that was used to protect the PKCS#12 file, and – if you don’t already have one – you’ll be required to choose a “master password” that will be used to protect all of your saved passwords and certificates in Opera.
Safari 3.1: On Windows, this uses the system-wide certificate store, as used by Internet Explorer. There is no inbuilt user interface for accessing this, so the easiest way to install certificates into Safari is to use Internet Explorer.
3. Tweaking Apache
Next, we’ll need to adapt our Apache configuration to ensure that SSL client certificates will be used for particular URLs. Our Rails application already allows username/password logins at /login (routed to AuthController.login). We’re going to configure Apache so that requests to /auth/certificate (AuthController.certificate) result in authentication using a client-side certificate, if available, and the details of the certificate used are to be passed on to the Rails application for user identification purposes (since it isn’t enough to just know that a user “is allowed” – we want to know which user they are!).
We already have a VirtualHost set up that looks a little like this:
<VirtualHost 18.104.22.168:443> ServerName secure.example.com # We're using Phusion Passenger, so we only need to specify a DocumentRoot DocumentRoot /path/to/our/rails/app # Enable SSL on this domain SSLEngine on SSLProtocol all SSLCipherSuite HIGH:MEDIUM SSLCertificateFile /path/to/server_certificate.pem SSLCertificateKeyFile /path/to/server_certificate.pem # Enable SSL client certificates, but disable verification for the entire domain (we only want it on specific URLs) SSLCACertificatePath /path/to/our/rails/app/ssl/demoCA SSLCACertificateFile /path/to/our/rails/app/ssl/demoCA/cacert.pem SSLVerifyClient none # Now, we enable use of SSL client certificates just for the /auth/certificate path (notice we're using Location, not Directory) <Location /auth/certificate> SSLVerifyClient require SSLOptions +ExportCertData SSLVerifyDepth 1 </Location> </VirtualHost>
What does this all mean? If you’re already got a secure site set up and running, most of it is familiar. The important new elements are:
- SSLCACertificatePath and SSLCACertificatePath point, for the VirtualHost, to your Certificate Authority. You’ll be using this to ensure that only client certificates signed by your Certificate Authority are trusted.
- SSLVerifyClient is set to “none” for the VirtualHost and “require” for the specific location for which we’ll be requiring certificates. In an ideal world, we’d simply set this to “optional” and use the /login URL (and make AuthController.login multi-purpose), but unfortunately the “optional” switch for SSLVerifyClientis not widely-supported and probably won’t work for you.
- SSLOptions +ExportCertData is an important option which tells Apache to pass on the details of the certificate with which authentication is taking place down to the Rails application, in the environment headers. This will pretty-much triple the size of the environment variable space for the duration of the request, so we only want to do it on this action, not throughout the application, if possible (we’ll be using the session to store the user’s identity once they’ve authenticated). This option is required because it is possible to do the authentication entirely within Apache using cleverly-constructed .htpasswd files containing certificate fingerprints, but we want to authenticate against our Rails application’s database.
- SSLVerifyDepth 1 specifies that all client-side certificates used must be directly signed by our CA’s certificate to be considered valid (so certificates that we did not issue are not even considered, reducing the overhead on our database).
If you reload your web server configuration and point a web browser at /auth/certificate on the server, you should now be prompted by your web browser to use your certificate. If you opt not to use the certificate or you use a certificate that has not been directly signed by your Certificate Authority, you’ll get an access denied or error page. If you use a valid certificate, you’ll see a Rails error (because we haven’t made the certificate action in the AuthController, yet.
4. Riding the Rails
Next up, we’ll configure our Rails application. The Rails application has two controllers, AuthController (which deals with authentication) and HomeController (which is accessible only to logged in users). AuthController has three methods: login (which shows the login page and processes login attempts), logout (which clears the session and redirects the user to the login page), and certificate (which attempts to log in using a browser certificate, once we’ve configured Apache correctly).
class AuthController < ApplicationController layout 'auth' def login if @me = User::find_by_id(session[:me]) # already logged in redirect_to :controller => 'home' and return elsif (request.xhr? or request.post?) # logging in if @me = User::login(params[:username], params[:password]) # successful login session[:me] = @me if request.xhr? # ajax login render :update do |page| page.redirect_to :controller => 'home' and return end else # POST login redirect_to :controller => 'home' and return end else # failed login if request.xhr? # ajax failed login render :update do |page| page['password'].value = '' # clear the password box page['flash'].innerHTML = 'Invalid username/password combination.' page.visual_effect 'highlight', 'flash' end return else # POST failed login flash[:login] = 'Invalid username/password combination.' end end end end def logout if session[:me] session[:me] = nil flash[:login] = 'Successfully logged out.' end redirect_to :action => 'login' end def certificate certificate = request.cgi.env_table['SSL_CLIENT_CERT'].gsub(/(\n|-----(BEGIN|END) CERTIFICATE-----)/, ''); if @me = User::find_by_ssl_certificate(certificate) session[:me] = @me.id if request.xhr? render :update do |page| page.redirect_to :controller => 'home' end else redirect_to :controller => 'home' end else if request.xhr? render :update do |page| page['flash'] = 'No certificate or invalid certificate supplied.' page.visual_effect 'highlight', 'flash' end else flash[:login] = 'No certificate or invalid certificate supplied.' redirect_to :action => 'login' end end end end
You’ll see how the basic username/password authentication works. When a request comes in to the login method (assuming the user isn’t already logged in, in which case they’re redirected to the home controller), it is checked for a valid username/password combination using the User::login method (whose implementation I’ll leave up to you). This particular authentication module supports plain old POST logins from regular HTML forms as well as remote Ajax forms (view implementation is shown below): on a failed Ajax login, the flash is updated live using RJS and the script.aculo.us “highlight” effect is used to draw attention to it, for example.
The certificate used to authenticate can be found in request.cgi.env_table[‘SSL_CLIENT_CERT’]. I use a regular expression to strip off the header and trailer and to concatenate all of the lines into a single string, then use User::find_by_ssl_certificate(certificate) to attempt to find a user to whom the specified certificate relates – there is a certificate column in my users table which contains the full public certificate, minus the header and footer lines, all concatenated onto one line. You can see this by editing the newcert.pem you create.
Here’s the code I used in my only view, login.html.erb:
A limitation of this approach to authentication is that web browsers do some curious and inconsistent things when they fail to authenticate for whatever reason. Some display a blank page, some display the previous page, and so on – so it’s hard to provide a “friendly” error message to those who fail to log in using their server-issued secure certificate. However, the method described above is still a perfectly workable solution with a little room for expansion and lots of scope for adaptation.
I hope you find it useful to be able to use this sophisticated level of security (which, when coupled with a password, genuinely succeeds at being a fabled “two-factor authentication on the web” approach) in your Ruby on Rails applications. Feel free to leave me a comment and let me know how it works out for you.
3 replies to SSL Client Auth in Rails
[…] Masters dissertation), so rather than write about it all here, I’ve written a howto/article: SSL Client Certificate Authentication In Ruby On Rails. If you’re at all interested in the topic, you’re welcome to have a read and give me […]
[…] I may cover implementation of these details on a Ruby webserver in the future, but for now you should reference the following article: SSL Client Certificate Authentication in Ruby on Rails […]
Instead of matching the whole certificate you might want to look up the DN from the certificate in the HTTP_X_SSL_CLIENT_S_DN header and check if it was validated against the CA in the HTTP-X-CLIENT-VERIFY header.
Comments are closed.