FTP Authentication via Rails

January 5th, 2009

Problem

We want to be able to allow users to upload files via FTP, and we want them to use the same accounts that they use on our Rails based site. How can we authenticate an FTP server against our Rails app?

Solution

If we use pure-ftp as a server, we can create a custom authentication module that the FTP service will use to validate logins.

Details on creating a custom authentication module for pure-ftp are found here: http://download.pureftpd.org/pub/pure-ftpd/doc/README.Authentication-Modules
The Gist: All we need to do is create a script that will use incoming environment variables and echo a handful of directives.

At minimum, we just need to handle the AUTHD_ACCOUNT and AUTHD_PASSWORD variables. We could do this with a simple rake task, being that rake already has access to environment variables. The downside to that solution is that rake is going to load up our Rails app every time someone tries to log in via FTP. The result is a slight delay in the login process and a spike in memory usage. Think of one of those FTP bots coming along and trying out as many logins as possible and imagine rake loading our Rails app into memory that many times at once.

Of course, we could always use a background process to load up one Rails instance once and then respond to it. Setting up a background processor can be a complicated process if you don’t already have one going for your app.

Since all we really need to do is return a blob of text, why not just hit the Rails app itself, via HTTP? We can create a controller action that returns plain text in the format required by pure-ftp and then use wget to grab it and echo it. With this solution, we’re hitting the web server, which is already loaded into memory, so there is no delay and no memory spike during login (well, only enough to run wget).

Let’s look at the minimum response we need to tell pure-ftp the user is valid:

auth_ok:1
uid:1000
gid:1000
dir:/path/to/rails/app/public/files/userid
end

The first line is either 1 or 0. 1 means the user is valid, 0 means they are invalid (bad login or password). According to the docs, pure-ftp will also accept -1, which means that the login was correct but the password was not. Since authentication plugins for Rails typically don’t distinguish between bad login/password and just bad password (why let the hackers know they guessed one half of the combo?) I’m going to return only 1 or 0.

uid and gid are the system level user and group that you want to use to run the service under. Pure-ftp is giving you the chance to increase security by assigning a unix-level user account to the login here, but since we’re not using unix-level accounts, we can just specify the user and group that has rights to the directory where the files will reside. If you’ve only created one user on your system and you’re deploying your Rails app under that user, chances are pretty good it’s going to be uid 1000 and gid 1000. You can find out by checking /etc/passwd:

jason@jason-1501:~$ grep jason /etc/passwd
jason:x:1000:1000:Jason LaPier,,,:/home/jason:/bin/bash

The dir directive is the directory we want the FTP user to start out in. In this example, we’re saying that if a user has id ‘99’, they get put into a directory at “RAILS_ROOT/public/files/99”.

Let’s take a look at the controller action that will spit out this block of text (I put this in a Users controller, but you can put it wherever you want):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
  # somewhere, we have to define SYSTEM_USER, SYSTEM_GROUP, and FTP_ROOT
  # I always stick these kinds of constants in another module so I can change them 
  # depending on the RAILS_ENV
  FTP_ROOT = "#{RAILS_ROOT}/public/files"
  SYSTEM_USER = '1000'
  SYSTEM_GROUP = '1000'

  # here's the action - we're expecting params: login, password
  def ftp_auth
    user = User.authenticate(params[:login], params[:password])
    if user
      user_root = File.join FTP_ROOT, user.id.to_s
      # mkdir_p is like "mkdir -p" - it creates the directory and parents as necessary,
      # doing nothing if they already exist
      FileUtils.mkdir_p user_root
      # chown_R is like "chown -R" - make sure the system user owns the files directory
      FileUtils.chown_R SYSTEM_USER, SYSTEM_GROUP, FTP_ROOT
      # build the output for a valid user. Be careful not to get whitespace at the
      # beginning of each line, or pure-ftp will ignore the output
      output = <<-END
auth_ok:1
uid:#{SYSTEM_USER}
gid:#{SYSTEM_GROUP}
dir:#{user_files_root(user)}
end
      END
    else
      # invalid user, so all we need is for auth_ok to be 0
      output = "auth_ok:0\n" + "end\n"
    end
    render :text => output
  end

If you are using sessions or other before_filters, don’t forget to take those out for this action. Since we’re not really making this connection with a web browser, most of that stuff is unnecessary. If you want to send the request via post, you may also need to take out forgery protection.

1
2
3
4
5
  # at the top of your controller, of course
  skip_before_filter :my_ridiculous_sidebar_loader, :only => :ftp_auth
  skip_before_filter :login_required, :only => :ftp_auth
  skip_before_filter :verify_authenticity_token, :only => :ftp_auth
  session :off, :only => :ftp_auth

Now let’s point our browser at the action and see what happens. If we go to http://localhost:3000/users/ftp_auth, we should get:

auth_ok:0 end

Since HTML ignores whitespace, you’ll have to view the page source to verify that the whitespace is correct. Better yet, whip up a controller test!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  # stick this in your controller's Test::Unit test

  # try with no params and you should get a failure
  def test_ftp_auth_fail
    out = post :ftp_auth
    assert_not_nil out.body
    assert_equal "auth_ok:0\nend\n", out.body
  end

  # this is a test user that came with the restful_authentication plugin
  def test_ftp_auth_valid
    out = post :ftp_auth, :login => 'aaron', :password => 'test'
    assert_not_nil out.body
    assert_equal "auth_ok:1\nuid:1000\ngid:1000\n" + 
      "dir:#{RAILS_ROOT}/public/files/#{users(:aaron).id}\nend\n", out.body
  end

Alright, we’re halfway there! Now we just need to set up pure-ftp to use a script for custom authentication.

Getting pure-ftp:
Most package installers will have it, or you can download it and compile it yourself fairly easily.

Setting up pure-ftp:
Pure-ftp doesn’t use a configuration file in it’s natural state – only command line arguments. Different Linux distributions come with different “wrappers” which parse configuration files and run pure-ftp with the appropriate arguments. Let’s just run pure-ftp from the command line for now.

First, we need a script that pure-ftp can call. This script just needs to hit the Rails app via HTTP and return the response, so we could whip up something in our favorite scripting language. For now, in the name of simplicity, let’s just shell-script a wget call:

#!/bin/sh
wget --post-data="login=$AUTHD_ACCOUNT&password=$AUTHD_PASSWORD" -qO- http://localhost:3000/users/ftp_auth | cat

Save this file (for example, as “ftp-auth.sh”) and make it executable (“chmod +x ftp-auth.sh”). If you run it now, you should get the “auth_ok:0” text (since the environment variables are not set). Start up pure-ftp’s auth daemon by running:

pure-authd -s /var/run/ftpd.sock -r /path/to/ftp-auth.sh -B

Next, start up the ftp server (remember to kill it first if it’s already running – especially if you installed pure-ftp with a package manager). Here’s an example with command line options:

pure-ftpd -lextauth:/var/run/ftpd.sock -X -A -F /pathto/banner -E -B -c 20 -C 2 -n 50:100

I’m using the following options in this example:
-A —chrooteveryone (very important! this will make the user’s home directory the virtual “root”, so the user can’t go up the directory tree and into your system)
-B —daemonize
-C —maxclientsperip (most ftp clients will try to use as many connections as possible, so you might want to limit this)
-c —maxclientsnumber (total number of connections you want to allow)
-d —verboselog (leave this out for production usage)
-E —noanonymous (no anonymous ftp users allowed)
-F —fortunesfile (optional – provide a custom banner – just point at a text file)
-n —quota (virtual quota – [number of files max:max file size]. 50:100 means 50 files, max of a single file is 100 Mb)
-X —prohibitdotfilesread (don’t show ‘hidden’ files or folders)

Now point your favorite FTP client at your server and try it out. You should be able to log in with a user in your Rails app, no problemo!

Sorry, comments are closed for this article.