Hi guys,
I thought I'd extend what I'd learnt through building the DokuDrupal authentication backend by writing a secure file download script for use with Drupal 7.
The brief I gave myself for the project was as follows:
- Files should be held in a location inaccessible to the web server, to avoid hotlinking
- The user must be authenticated with the Drupal instance held on the server in order to use the download script
- No filenames must be visible from the URLs given.
The project was actually a lot simpler than I initially thought it was going to be. A lot of the authentication code could be copied almost directly from the Dokuwiki auth backend, as the basic method of checking authentication was just done in a PHP loop through all the users local cookies. I'll go through each of the functions I wrote individually, then show how they're used to confirm authentication.
Here's a link to the whole code without breaks in my Stikked pastebin.
The workhorse function for the script is "checkAuth". It starts by finding a cookie which looks like a Drupal 7 session cookie. For any cookie it finds which matches the regex '/^SESS[\w]{32}$/', it will proceed to try and find it in the Drupal sessions table. If it succeeds, it returns true. If, however, it loops through all user cookies, and none match the criteria, or are found in the database, it will return false.
/**
* ******************************************
* ** Drupal Secure Download Script **
* ******************************************
*
* Author: Alex Shepherd <n00batnospamn00bsys0p.co.uk>
* License: GPL2 (http://www.gnu.org/licenses/gpl-2.0.html)
*
**/
/**
* ***************
* ** checkAuth **
* ***************
*
* Check whether the user is currently logged into
* Drupal.
*
* return bool True if user has cookies which match
* the Drupal database.
**/
function checkAuth() {
// Initialise function variables
$sessioncookie = '';
$uid = '';
$username = '';
$cookies = $_COOKIE;
// Read in Drupal settings file
include('../sites/default/settings.php');
$dbhost = $databases['default']['default']['host'];
$dbpass = $databases['default']['default']['password'];
$dbuser = $databases['default']['default']['username'];
$dbname = $databases['default']['default']['database'];
// Main processing loop
foreach($cookies as $key=>$val) {
// Ignore any cookie with anything but alphanumeric characters
if(!preg_match('/^[\w-]+$/', $key)
|| !preg_match('/^[\w-]+$/', $val)) {
continue;
}
// If we are using HTTPS, the first chars of the
// Drupal cookie are SSESS. Drupal's HTTP cookies
// start with SESS.
// The commented line is the equivalent of the one below, which uses regex instead
//if(substr($key, 0, 4) == 'SESS' && strlen($key) == 36) {
if(preg_match('/^SESS[\w]{32}$/', $key)) {
$sessioncookie = $val;
} else {
continue;
}
// Now we have a cookie, find it in the database
mysql_connect($dbhost, $dbuser, $dbpass) or die('Critical failure. Cannot connect to database.');
mysql_select_db($dbname) or die('Critical failure. Cannot open database.');
// Bear in mind that if using HTTPS, "s.sid" should be changed to "s.ssid"
$sql = 'SELECT u.uid,name FROM users u, sessions s WHERE u.uid=s.uid AND s.sid="'.$sessioncookie.'"';
$result = mysql_query($sql);
if(mysql_num_rows($result) > 0) {
$row = mysql_fetch_array($result);
$uid = $row['uid'];
$username = $row['name'];
break;
} else continue; // No rows found, so on to the next cookie
}
// If we have user details after the loop, user is authenticated
if($uid != '' && $username != '') return true;
// All cookies have been checked, and all have failed
return false;
}</n00batnospamn00bsys0p.co.uk>
The next function of note, is the one which matches the name of a file, unavailable to the outside world, except via this script. If you know any PHP at all, it's pretty simple. You fire it a number, and it tells you if there's a filename attached.
/**
* Match the number given to a filename to download.
*
* return string String will either be a filename or blank
*
**/
function getFileName($filenum) {
$fname = '';
// Check for a single number in the input
if(!preg_match("/^[0-9]$/", $filenum)) return '';
switch($filenum) {
case 1:
$fname = 'UserManual.pdf';
break;
case 2:
$fname = 'EngineerManual.pdf';
break;
}
return $fname;
}
The next function just does a meta http refresh after 3 seconds back to the referring page.
/**
* Redirect to the referring page
*
* return n/a
**/
function redirectToPrevious() {
echo "<meta http-equiv="Refresh" content='\"3;' url=".$_SERVER[HTTP_REFERER].">";
}
The last function just reads a file from somewhere on the filesystem on the fly, and passes it to the user.
/**
* Pass a download link to the user.
*
* return n/a
**/
function downloadFile($file) {
header("Content-type: application/force-download");
header("Content-Transfer-Encoding: Binary");
header("Content-length: ".filesize($file));
header("Content-disposition: attachment; filename=\"".basename($file)."\"");
readfile("$file");
}
Here's the code for the script file itself. It's pretty self explanatory. $dir is the folder outside the web folder's accessible area, but still accessible by Apache (for those of you running in a chroot).
// Set variables.
$dir='/srv/filerepo/';
$filename='';
// Check authentication
if(!checkAuth()) {
header("HTTP/1.1 403 Forbidden");
exit;
}
// Sanitise user input by only allowing digits
$getnum = filter_input(INPUT_GET, 'file', FILTER_SANITIZE_NUMBER_INT);
// Process input and download file
$filename = getFileName($getnum);
if($filename == '') {
header('HTTP/1.0 404 Not Found');
include('404.php');
exit;
}
$file = $dir.$filename;
downloadFile($file);
redirectToPrevious();
As always, folks, feel free to leave me feedback in the comments, or via my contact form!
n00b