This chapter draws on knowledge gained from previous chapters of CGI Programming 101, including file I/O (chapter 6), data validation with regular expressions (chapter 14), encryption (chapter 10) and database interaction (chapter 18). You'll need some experience with these advanced topics to work through some of the examples in this chapter.
Copyright © 2000 by Jacqueline D. Hamilton.
User Authentication
One important feature for web sites is the ability to restrict access to part or all of the site. This is often used on subscription sites, such as online webzines or other members-only services. It's also used on administrative portions of web sites, as well as on secure sites for online banking and stock trading. Some online catalogs even require you to register with a username and password before you can order from them (a trend I don't recommend - if you want people to buy from you, you should make it as easy as possible, rather than force them to remember yet another username/password).
There are two kinds of user authentication. One is HTTP authentication; this is actually done by the web server itself. You create a .htaccess file (containing authentication instructions) in the web directory that you want to protect. Then whenever someone tries to access a web page in that directory, their browser presents a pop-up box that asks for their username and password:
The other kind of authentication is done by forms and CGIs, and often uses cookies to track user sessions. This is a much harder method to implement, as it requires you to add code to every page you want protected - and in fact, it will not protect basic HTML files or images at all, so you'd likely have to create CGIs to serve up your entire site (or do some other fancy server manipulation to make it work). You will need this method if you want to limit the amount of time a user stays online/idle, or to restrict logins so that a given user can't have more than one simultaneous login.
The examples in this chapter all deal with HTTP authentication. It is by far the easiest method to set up and maintain.
Designing Passworded Sites
Give some thought to the reasons for the password protection, and the level of security you need. If you're setting up a developmental site to share designs or documents with a handful of people, then a single username/password may be sufficient. For an intranet site accessible only to people from a certain domain, you may not even need a username/password - you can restrict access based on domain alone.
Keep in mind that unless you are using a secure server (where your protected pages are all being accessed via a
https://url), usernames and passwords are sent over the web "in the clear", and are not encrypted. It's possible for someone to run a "packet sniffer" (a program that intercepts internet traffic) - and if they do, they've got your username and password. If you're providing or asking for any kind of secure data (credit card or bank information, stocks, etc.), you need to use a secure server.Also, if you have more than a hundred users accessing a hidden area, you should use a database (along with the appropriate
mod_authmodule compiled into the server) for lookups. The web server has to look up the username in the auth table for _every page_ that's being accessed, even after a user has logged in; if you use a flat password file for this, your server may get bogged down from excessive file I/O.Basic HTTP Authentication
With HTTP authentication, you can only password-protect a directory and the files within; it's not possible to protect a single file with this method. Here's how to set up a passworded directory.
First, create a subdirectory in your web space. For this example we'll create one named "secure". Set the permissions on the directory so that it's world readable/executable. Here are the Unix shell commands:
mkdir secure chmod 755 secure cd secureNext you'll need to create a .htaccess file inside the secure directory. Make it a new file, and enter the following data. The items in bold are things you will want to change depending on the location of these files and directories on your server.
AuthUserFile /home/www/class/password/secure/.htpasswd AuthName "Password Example" AuthType Basic <limit GET> require valid-user </limit> # If you are using an Apache server, you should add these # lines as well, to prevent users from downloading these # files: <files .htaccess> Order allow,deny Deny from all </files> <files .htpasswd> Order allow,deny Deny from all </files>The AuthName is what the user will see when they're prompted for a password - "Enter Authorization for <AuthName>". If AuthName is more than one word, you'll need to enclose it in quotes, or you'll get an error instead of a password prompt when accessing the pages in that directory.
Now you'll set up the password file. You'll need to use the htpasswd program to do this. It is included with the Apache server, usually in the support subdirectory under the server root (try /usr/local/apache/bin, for Apache 1.3 and later). If you can't find it or your server doesn't have this program, you can download one - see the resources list at the end of the chapter.
Now for every user you want to add to the password file, enter the following. (the -c flag is only required the first time; it indicates that you want to create the .htpasswd file).
htpasswd -c .htpasswd user1 [ you're prompted for the password for user1] htpasswd .htpasswd user2 [ you're prompted for the password for user2] htpasswd .htpasswd user3 [ you're prompted for the password for user3]chmod both files (.htaccess and .htpasswd) to mode 644, so the webserver can read them. Now, when you access the secure directory via your browser, you should be prompted for a username and password.
Working example: http://www.cgi101.com/class/password/secure/ - username is "webuser" and password is "foobar1".
You don't have to name the password file '.htpasswd'; it can be any file name. Just be sure to put the full path to the file name in the .htaccess file.
Creating a User Registration CGI
This example shows you how to create a form and CGI to allow users to register on your site, creating a username and password for themselves. This script writes directly to the .htpasswd file in the protected directory, and may be a security risk. If you are on a shared server (such as an ISP) and your CGIs don't run with your userid and permissions, then the only way this will work is if your .htaccess file is world-writable. This is dangerous, so you may want to consider using something like mod_auth_mysql instead (see below).
First, you'll need to create a registration form. At the very least, you'll need fields for the username and password. You may want to request additional information from the user, such as their full name and e-mail address. This extra info can be stored in a separate file or database. Here's an example user registration form:
<html><head><title>Register for FooWeb!</title></head> <body> <form action="register.cgi" method="POST"> Register for FooWeb!<p> Your Name: <input type="text" name="realname"><br> E-Mail Address: <input type="text" name="email"><br> Desired Username: <input type="text" name="username"><br> Password: <input type="password" name="password"><br> <input type="submit" value="Register"> </form> </body></html>Now you'll need a CGI to parse the form data, and do some validation before writing the password information to the .htaccess file. The new user's name and e-mail address are written to a separate data file called .userinfo.
#!/usr/bin/perl # use CGI::Carp qw(fatalsToBrowser); $passfile = 'secure/.htpasswd'; $userfile = 'secure/.userinfo'; # for the userfile, ideally you should put it outside of the web # tree, so it can't be fetched via a browser - or, if you put it # in the same directory, be sure to protect it in the .htaccess # file with the <file> directive. print "Content-type:text/html\n\n"; # first see if the files exist and are writable - if not, there's no point # in proceeding further... if (! (-w $passfile)) { &dienice("Can't write to passfile $passfile: $!"); } if (! (-w $userfile)) { &dienice("Can't write to userfile '$userfile': $!"); } # one caveat to the above is it won't work if the files aren't there # (e.g. for the very first user). you can create empty files for them # by typing: # # touch .htpasswd .userinfo # chmod 664 .htpasswd .userinfo read(STDIN, $buffer, $ENV{'CONTENT_LENGTH'}); @pairs = split(/&/, $buffer); @keys = (); foreach $pair (@pairs) { ($name, $value) = split(/=/, $pair); $value =~ tr/+/ /; $value =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack("C", hex($1))/eg; push(@keys, $name); $FORM{$name} = $value; } $username = $FORM{'username'}; $password = $FORM{'password'}; $realname = $FORM{'realname'}; $email = $FORM{'email'}; # First, do some data validation. # be sure the username is alphanumeric - no spaces or funny characters if ($username !~ /^\w*$/) { &dienice("Please use an alphanumeric username, with no spaces."); } # be sure their real name isn't blank if ($realname eq "") { &dienice("Please enter your real name."); } # be sure the password isn't blank or shorter than 6 chars if (length($password) < 6) { &dienice("Please enter a password at least 6 characters long."); } # be sure they gave a valid e-mail address # (this uses the email-address pattern match from chapter 14) if ($email !~ /[\w\-]+\@[\w\-]+\.[\w\-]+/) { &dienice("Please enter a valid e-mail address."); } # now encrypt the password $encpass = &encrypt($password); open(inf,$passfile) or &dienice("Can't open password file."); @passf = <inf>; close(INF); # the structure of the htpasswd file is: # username:passwd # username:passwd # ...etc., with each user's record on a separate line. # here we're going to loop through and make sure the new username # doesn't already exist in the htpasswd file. foreach $i (@passf) { chomp($i); ($u,$p) = split(/:/,$i); if ($u eq $username) { &dienice("The username `$username' is already in use. Please choose another."); } } # everything seems clear now, so write the info to the data files. open(PASSF,">>$passfile") or &dienice("Can't write to password file: $!"); print PASSF "$username:$encpass\n"; close(PASSF); open(USRF,">>$userfile") or &dienice("Can't write to user info file: $!"); print USRF "$username:$realname:$email\n"; close(USRF); print <<endhtml; <html><head><title>Registration Successful!</title></head> <body> You're now registered! Your username is <b>$username</b>, and your password is <b>$password</b>. Login <a href="secure/">here</a>.<p> </body> </html> EndHTML sub encrypt { my($plain) = @_; my(@salt); @salt = ('a'..'z', 'A'..'Z', '0'..'9', '.', '/'); srand(time() ^ ($$ + ($$ << 15)) ); return crypt($plain, $salt[int(rand(@salt))] . $salt[int(rand(@salt))] ); } sub dienice { my($msg) = @_; print "<h2>Error</h2>\n"; print $msg; exit; }Source Code: http://www.cgi101.com/class/password/register.txt
Working Example: http://www.cgi101.com/class/password/register.html
Remember you'll have to change the permissions of the .htpasswd and .userinfo files so that they're writable by the web server process.
A Better Method: Authentication via Database
Leaving your .htpasswd files writable by anyone is a bad idea, unless you're the only person with a user account on your web server's machine... and even then, there's some risk. There are many other ways you can authenticate users, however. The Apache server has a number of contributed modules available for this purpose; a search for "mod_auth" on the Apache Module Registry (http://modules.apache.org/) turns up dozens.
Since we've done a lot of work already with MySQL, these next examples will deal with user authentication using mod_auth_mysql. When compiled into Apache, this module allows you to store usernames and passwords in a MySQL database. You'll still need to create a .htaccess file to make the directory password-protected. Here's an example of a basic .htaccess file for mod_auth_mysql:
Auth_MYSQL_DB usertable Auth_MYSQL_Password_Table users Auth_MySQL_Username_Field username Auth_MYSQL_Password_Field password Auth_MYSQL_Empty_Passwords Off AuthName "Members-Only Area" AuthType Basic require valid-userThe format of the htaccess file is pretty self-explanatory. Auth_MYSQL_DB is the name of the database which contains the username/password info. Auth_MYSQL_Password_Table is the name of the table inside the database which contains the username/password info. The Auth_MYSQL_Username_Field and Auth_MYSQL_Password_Field directives specify the name of the columns in your password table that will be used for usernames and passwords, respectively. (You can call them whatever you like, just be sure to specify them in the htaccess file.)
There are more advanced ways to set up the file; you can, for example, maintain a database of subscribers and use a third column such as "Status" to determine whether the user can access the area:
Auth_MYSQL_DB usertable Auth_MYSQL_Password_Table users Auth_MySQL_Username_Field username Auth_MYSQL_Password_Field password Auth_MYSQL_Empty_Passwords Off Auth_MYSQL_Group_Table users Auth_MYSQL_Group_Field status AuthName "Members-Only Area" AuthType Basic require group CURRENTThe Auth_MYSQL_Group_Table and Auth_MYSQL_Group_Field directives specify what table and field to look at for the group information.
So, in this example, you could have different statuses such as "CURRENT" and "EXPIRED", and only allow users whose subscriptions are "CURRENT" to access the area. When their subscriptions expire, you just change their status to "EXPIRED" without deleting them from the database. This preserves their login information (and whatever other info you stored about them), in case they decide to renew later.
Let's try it. We'll create a MySQL table called "users":
mysql> create table users( username char(20) not null primary key, password char(40) not null, status enum('CURRENT','EXPIRED','SUSPEND') not null, name char(80) not null, email char(80) not null);Now to automatically add users to your database, you'll use the same registration form as before, then modify the register.cgi to query and update the user database:
#!/usr/bin/perl # use DBI; use CGI::Carp qw(fatalsToBrowser); print "Content-type:text/html\n\n"; $dbh = DBI->connect( "dbi:mysql:usertable", "usertable", "jutedi2") or &dienice("Can't connect to db: ",$dbh->errstr); read(STDIN, $buffer, $ENV{'CONTENT_LENGTH'}); @pairs = split(/&/, $buffer); @keys = (); foreach $pair (@pairs) { ($name, $value) = split(/=/, $pair); $value =~ tr/+/ /; $value =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack("C", hex($1))/eg; push(@keys, $name); $FORM{$name} = $value; } $username = $FORM{'username'}; $password = $FORM{'password'}; $realname = $FORM{'realname'}; $email = $FORM{'email'}; # First, do some data validation. # be sure the username is alphanumeric - no spaces or funny characters if ($username !~ /^\w*$/) { &dienice("Please use an alphanumeric username, with no spaces."); } # be sure their real name isn't blank if ($username eq "") { &dienice("Please enter your real name."); } # be sure the password isn't blank or shorter than 6 chars if (length($password) < 6) { &dienice("please enter a password at least 6 characters long."); } # be sure they gave a valid e-mail address # (this uses the email-address pattern match from chapter 14) if ($email !~ /[\w\-]+\@[\w\-]+\.[\w\-]+/) { &dienice("please enter a valid e-mail address."); } # check the db first and be sure the username isn't already registered $sth = $dbh->prepare("select * from users where username = ?") or &dienice("Can't select from table: ",$dbh->errmsg); $sth->execute($username); @out = $sth->fetchrow; if ($#out >= 0) { &dienice("The username `$username' is already in use. Please choose another."); } # ok, it's not, so add them to the database. # we're going to encrypt the password first, then store the encrypted # version in the database. $encpass = &encrypt($password); $sth = $dbh->prepare("insert into users values(?, ?, ?, ?, ?)") or &dienice("Can't add data to user table: ",$dbh->errmsg); $sth->execute($username, $encpass, "CURRENT", $realname, $email); print <<EndHTML; <html><head><title>Registration Successful!</title></head> <body> You're now registered! Your username is <b>$username</b>, and your password is <b>$password</b>. Login <a href="secure2/">here</a>.<p> </body> </html> EndHTML sub encrypt { my($plain) = @_; my(@salt); @salt = ('a'..'z', 'A'..'Z', '0'..'9', '.', '/'); srand(time() ^ ($$ + ($$ << 15)) ); return crypt($plain, $salt[int(rand(@salt))] . $salt[int(rand(@salt))] ); } sub dienice { my($msg) = @_; print "<h2>Error</h2>\n"; print $msg; exit; }Source Code: http://www.cgi101.com/class/password/register2.txt
Working Example: http://www.cgi101.com/class/password/register2.html
Resetting Passwords
Whenever you set up password-protected areas, be prepared to provide support for users who forgot (or want to change) their usernames and passwords. There are two separate form/CGIs needed to do this. First you'll need a script to handle cases where someone has lost or forgotten their password. This script will need to be outside the password-protected area (since, if they don't have their password, they obviously can't login to change it). The script will reset the password and email it to their registered email address (provided you've saved it somewhere).
The second case is when someone knows their password, and wants to change it. This script can be saved in the password-protected area of your site.
Let's start with he 'forgot my password' example. In this example, we have a form that prompts for the visitor's username, along with their e-mail address (which we requested on the original registration form).
<html><head><title>Reset Your Password</title></head> <body> <form action="forgotpass.cgi" method="POST"> Use this form to reset your password. (A new password will be e-mailed to you.)<p> E-Mail Address: <input type="text" name="email"><br> Your Username: <input type="text" name="username"><br> <input type="submit" value="Change Password"><p> </form> </body></html>Now the CGI will look up the username and e-mail address in the user information file. We want to verify that the person requesting the change is really the person who registered the userid. If so, then we'll reset the password and mail it to the user's e-mail address.
#!/usr/bin/perl use DBI; use CGI::Carp qw(fatalsToBrowser); print "Content-type:text/html\n\n"; $dbh = DBI->connect( "dbi:mysql:usertable", "usertable", "jutedi2") or &dienice("Can't connect to db: ",$dbh->errstr); read(STDIN, $buffer, $ENV{'CONTENT_LENGTH'}); @pairs = split(/&/, $buffer); @keys = (); foreach $pair (@pairs) { ($name, $value) = split(/=/, $pair); $value =~ tr/+/ /; $value =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack("C", hex($1))/eg; push(@keys, $name); $FORM{$name} = $value; } $username = $FORM{'username'}; $email = $FORM{'email'}; $sth = $dbh->prepare("select * from users where username = ?") or &dienice("Can't select from table: ",$dbh->errmsg); $sth->execute($username); $hashref = $sth->fetchrow_hashref; %uinfo = %{$hashref}; if (!(scalar %uinfo)) { &dienice("Username '$username' is not registered. <a href="register2.cgi">Register today!</a>"); } # even if the username is valid, we want to check and be sure the email # address matches. if ($uinfo{email} !~ /$email/i) { &dienice("The email address '$email' does not match what's stored in the user database."); } # ok, it's a valid user. First, we create a random password. This uses # the random password code from chapter 10. $randpass = &random_password(); # now we encrypt it: $encpass = &encrypt($randpass); # now store it in the database... $sth = $dbh->prepare("update users set password=? where username=?") or &dienice("Can't add data to user table: ",$dbh->errmsg); $sth->execute($encpass, $username); # ...and send email to the person telling them their new password. # be sure to send them the un-encrypted version! $mailprog = "/usr/sbin/sendmail"; open(MAIL,"|$mailprog -t"); print MAIL "To: $email\n"; print MAIL "From: webmaster\n"; print MAIL "Subject: Your FooWeb Password\n\n"; print MAIL <<endmail; your fooweb password has been changed. the new password is '$randpass'. you can login and change your password at http://www.cgi101.com/class/password/secure2/passchg.html. endmail # and finally we need to print out a thank-you page telling the user what # we've done. print <<endhtml; <html><head><title>Password Reset</title></head> <body> <h2>Success!</h2> Your password has been changed! A new password has been e-mailed to you.<p> </body> </html> EndHTML sub encrypt { my($plain) = @_; my(@salt); @salt = ('a'..'z', 'A'..'Z', '0'..'9', '.', '/'); srand(time() ^ ($$ + ($$ << 15)) ); return crypt($plain, $salt[int(rand(@salt))] . $salt[int(rand(@salt))] ); } sub random_password { my($length, $vowels, $consonants, $alt, $s, $newchar, $i); ($length) = @_; if ($length eq "" or $length < 3) { $length = 6; # make it at least 6 chars long. } $vowels = "aeiouyAEUY"; $consonants = "bdghjmnpqrstvwxzBDGHJLMNPQRSTVWXZ12345678"; srand(time() ^ ($$ + ($$ << 15)) ); $alt = int(rand(2)) - 1; $s = ""; $newchar = ""; foreach $i (0..$length-1) { if ($alt == 1) { $newchar = substr($vowels,rand(length($vowels)),1); } else { $newchar = substr($consonants, rand(length($consonants)),1); } $s .= $newchar; $alt = !$alt; } return $s; } sub dienice { my($msg) = @_; print "<h2>Error</h2>\n"; print $msg; exit; }Source Code: http://www.cgi101.com/class/password/forgotpass.txt
Working Example: http://www.cgi101.com/class/password/forgotpass.html
Change Password
This is the second support script needed for a passworded site - it handles cases where the user knows their password and just wants to change it to something else. It's good to provide a URL to this page whenever you reset (randomize) someone's password, as with the above script, because they'll want to reset it to something they can remember - rather than try to remember the randomized password.
Since this form will be in the passworded area, you don't NEED to ask them for their password again, but it's probably a good idea. This prevents someone from changing the password should the real user walk off and leave their browser unattended.
<html><head><title>Password Change</title></head> <body> <form action="passchg.cgi" method="post"> Use this form to change your password.<p> Old Password: <input type="password" name="oldpass"><br> New Password: <input type="password" name="newpass1"><br> New Password: <input type="password" name="newpass2"><br> <input type="submit" value="change password"><p> </form> </body></html>Now for the CGI. Notice we didn't ask for the username - that can be gotten from any script in the password-protected area by looking at $ENV{'REMOTE_USER'}.
Also, to verify the old password, we're using a slightly different method of encrypting to compare it to the one that's saved in the database. In earlier scripts, we've been using code like this:
@salt = ('a'..'z', 'A'..'Z', '0'..'9', '.', '/'); return crypt($plain, $salt[int(rand(@salt))] . $salt[int(rand(@salt))];to encrypt the password. The "salt" is a two-character string randomly chosen from the set [a-zA-Z0-9./] (which is stored in the @salt array). The salt string is used to randomize the encryption routine, thus making the encrypted string harder to crack. If you take the same string and encrypt it with a different pair of salt characters, you'll get a completely different encrypted string. But, if you encrypt it with the SAME pair of salt characters, you'll get the same encrypted string. Also, the frist two characters of the encrypted string ARE the salt pair that was used to encrypt it. This means you can take the first two characters of the stored encrypted password, use it as salt on the value the user typed in as their old password, and - if they typed the correct password - the resulting encrypted string will match what's stored in the database. Specifically:
$encpass = crypt($oldpass,substr($storedpass,0,2));If $oldpass is the same password that $storedpass was before encrypted, then $encpass will be equal to $storedpass.
Here's the complete password change script:
#!/usr/bin/perl use DBI; use CGI::Carp qw(fatalsToBrowser); print "Content-type:text/html\n\n"; $dbh = DBI->connect( "dbi:mysql:usertable", "usertable", "jutedi2") or &dienice("Can't connect to db: ",$dbh->errstr); read(STDIN, $buffer, $ENV{'CONTENT_LENGTH'}); @pairs = split(/&/, $buffer); @keys = (); foreach $pair (@pairs) { ($name, $value) = split(/=/, $pair); $value =~ tr/+/ /; $value =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack("C", hex($1))/eg; push(@keys, $name); $FORM{$name} = $value; } $oldpass = $FORM{'oldpass'}; $newpass1 = $FORM{'newpass1'}; $newpass2 = $FORM{'newpass2'}; $username = $ENV{'REMOTE_USER'}; $sth = $dbh->prepare("select * from users where username = ?") or &dienice("Can't select from table: ",$dbh->errmsg); $sth->execute($username); $hashref = $sth->fetchrow_hashref; %uinfo = %{$hashref}; if (!(scalar %uinfo)) { # this really shouldn't ever happen... &dienice("Can't find your username!?"); } # now encrypt the old password and see if it matches what's in the database if ($uinfo{password} ne crypt($oldpass,substr($uinfo{password},0,2)) ) { &dienice("Your old password is incorrect. If you can't remember it, please use the <a href="../forgotpass.html">reset password</a> form instead."); } # a little redundant error checking to be sure they typed the same # new password twice: if ($newpass1 ne $newpass2) { &dienice("You didn't type the same thing for both new password fields. Please check it and try again."); } # ok, everything checks out. Now we encrypt the new one: $encpass = &encrypt($newpass1); # now store it in the database... $sth = $dbh->prepare("update users set password=? where username=?") or &dienice("Can't add data to user table: ",$dbh->errmsg); $sth->execute($encpass, $username); # we're not sending mail this time. # Finally we print out a thank-you page telling the user what # we've done. print <<EndHTML; <html><head><title>Password Changed</title></head> <body> <h2>Success!</h2> Your password has been changed! Your new password is <b>$newpass1</b>.<p> <a href="/class/password/secure2/">Click Here</a> to login again!<p> </body> </html> EndHTML sub encrypt { my($plain) = @_; my(@salt); @salt = ('a'..'z', 'A'..'Z', '0'..'9', '.', '/'); srand(time() ^ ($$ + ($$ << 15)) ); return crypt($plain, $salt[int(rand(@salt))] . $salt[int(rand(@salt))] ); } sub dienice { my($msg) = @_; print "<h2>Error</h2>\n"; print $msg; exit; }Source Code: http://www.cgi101.com/class/password/passchg.txt
Working Example: http://www.cgi101.com/class/password/secure2/passchg.html
Resources
NCSA Mosaic User Authentication Tutorial - http://hoohoo.ncsa.uiuc.edu/docs/tutorials/user.html
There is also a htpasswd Perl module available on CPAN: http://search.cpan.org/search?dist=Apache-Htpasswd