Web Sessions
With the advent of stored procedures in MySQL 5, it is now possible to hand off all web session managment to the database directly. There are arguments for and against doing this. Against: It will put more load on your database server (ie, your primary database server, since all requests must go against this host, and not your read-only slave host(s)). However, it can be more efficient since you don't have to prepare and execute several distinct queries to perform the same operation.
This was developed against MySQL 5.0.3 on WinXP.
Table Structures
Variables
We need to define a few variables, so we'll make a table to hold simple name = value pairs.
create table variable (name varchar(255) primary key, index index_name(name(10)), value text not null); insert into variable VALUES ('idle_session_timeout_seconds', '600'), ('session_max_time', '0'), ('session_change_ip_allowed', '1'), ('purge_old_sessions_percent', '10'), ('site_enabled', '1'), ('site_enabled_message', 'Site maintenence crew are working; normal service will return at 2am');
User table
Our user table is going to be huge; number of columns and number of rows. Add columns to store what you want for your users.
create table users ( id int unsigned primary key auto_increment, email text not null, index index_email(email(20)), name_first text, name_last text, password text, password_previous text, password_change_time datetime, last_login datetime, last_access datetime, disabled bool default 0, reset_password bool default 0, reset_question text, reset_answer text);
Sessions
Our session table is hot. Every web hit will update this table. It can't lock at all. It must be as tiny as possible - for column sizes and number of rows. Any delay in finding the matching cookie and updating its last access time will slow the entire site down.
create table sessions ( cookie char(36) primary key, user_id int unsigned not null, session_start_time datetime not null, last_access datetime not null, index index_last_access(last_access));
We have this as a char(36) so that all fields have a set size, and thus MySQL deems this as a FIXED table. For MyISAM tables, this can be a speed improvment, since the server can skip rows by a set offset. Thus, each row here will be 36 + 4 + 8 = 48 bytes (plus some for the index, around an extra 8 bytes for the index).
Login history
We'll record an ever growing list of logins and login failures into two separate tables. Over time, we'll dump the login failures reasonably regularly (we probably only want to look at the failures over the last 10 minutes anyway to look for repeated crack attempts). This will get very long, so size is again important.
create table logins ( user_id int unsigned not null, index index_user_id(user_id), login_time datetime not null, duration int unsigned, address int not null);
create table login_failures ( email text, failure_time datetime not null, address int not null);
Note that the login table has a duration; we'll fill this in at the end of the session when we clear it out. We'll build up a usage pattern for each user. Also, we're going to be naive and assume that the ip address doesnt change from the start of the session to the end. This is of course feasable, just I'm not that bothered by it. Checking for this constraint not changing can be done in the login() stored procedure.
Stored Procedures
Now we have our table structures in place, we can create our SQL procedures that will drive the authentication. We want four procedures:
- login - given an email address and password, return a cookie value if it is valid
- cookie_session - given a cookie, return a user_id if it is valid
- user_change_password - given a user_id and a new password, change it if it is not the same as the old password
- session_cleanup - delete old sessions, updating user last access times
Login
When we have a login event, we want to:
- Check the supplied password against the stored password
- Check the account is not disabled
- Generate a cookie value (unique random string)
- INSERT this cookie into the session table
- UPDATE the user's last login time
create procedure login (IN email1 text, IN password1 text, IN address1 int, OUT cookie varchar(36), OUT user_id int unsigned) language SQL comment 'Returns a cookie UUID if successful' NOT DETERMINISTIC BEGIN declare all_done INT(1); declare this_user_id int unsigned; declare saved_password text; declare cur_1 CURSOR FOR SELECT id, password FROM users WHERE email = email1; declare cur_2 CURSOR FOR SELECT uuid(); declare continue handler for not found SET all_done = 1; SET all_done = 0; OPEN cur_1; FETCH cur_1 into this_user_id, saved_password; IF all_done != 1 THEN IF saved_password = password1 THEN OPEN cur_2; FETCH cur_2 into cookie; SET user_id = this_user_id; INSERT INTO logins (user_id, login_time, address) VALUES (this_user_id, now(), address1); INSERT INTO sessions (user_id, session_start_time, last_access, cookie) VALUES (this_user_id, now(), now(), cookie); UPDATE users SET last_login = now() where id = this_user_id; CLOSE cur_2; ELSE INSERT INTO login_failures (email, failure_time, address) VALUES (email1, now(), address1); END IF; END IF; CLOSE cur_1; END ; //
Cookie_session
When a cookie is presented to the web site, we want to:
- Check if the cookie is in the session table already
- Unset the cookie if it is not in the session table
- UPDATE the session last_access time
- NOT update the user.last_access time, since we do not want this table getting hot, and it doubles our work here
create procedure cookie_session (IN cookie1 varchar(36), OUT this_user_id int unsigned) language SQL NOT DETERMINISTIC comment 'Returns the user_id if successful' BEGIN declare all_done, this_do_update INT(1); declare cur_1 CURSOR FOR SELECT user_id from sessions WHERE cookie = cookie1 AND unix_timestamp(now()) - unix_timestamp(last_access) < (SELECT value from variable where name = 'idle_session_timeout_seconds') ; declare cur_2 CURSOR FOR SELECT rand() * 100 < (SELECT value from variable where name = 'purge_old_sessions_percent') as do_update ; declare continue handler for not found SET all_done = 1; SET all_done = 0; open cur_1; FETCH cur_1 INTO this_user_id; IF all_done != 1 THEN UPDATE sessions SET last_access = now() WHERE cookie = cookie1; END IF; CLOSE cur_1; open cur_2; FETCH cur_2 into this_do_update; IF this_do_update = 1 THEN call clean_sessions(@count); END IF; END ;//
Logout
On logout, we want to update the user's last access time, and then work out how long the session is that we are about to remove, and update one of the login entries that corresponds to this session with this duration.
create procedure logout (IN cookie1 varchar(36), OUT result int(1)) language SQL NOT DETERMINISTIC comment 'Removes the cookie session, updating other tables' BEGIN declare all_done int(1); declare this_last_access datetime; declare this_duration, this_user_id int unsigned; declare cur_1 CURSOR FOR SELECT last_access, last_access - session_start_time, user_id from sessions where cookie = cookie1; declare continue handler for not found SET all_done = 1; SET all_done = 0; open cur_1; FETCH cur_1 into this_last_access, this_duration, this_user_id; close cur_1; update users set last_access = this_last_access where id = this_user_id and last_access < this_last_access limit 1; update logins set duration = this_duration where user_id = this_user_id and duration is null limit 1; delete from sessions where cookie = cookie1 limit 1; END; //
User_change_password
When we change the pasword, we want to ensure that it is not being set the same as the current password, and also record the time it was changed. Also, we want to set the rest_password flag to false, since we have just reset the password.
create procedure user_change_password (IN user_id1 int unsigned, IN password_new text, OUT result int) language SQL comment 'Returns either true or false' BEGIN declare all_done INT(1); declare this_password text; declare cur_1 CURSOR FOR SELECT password from users where id = user_id1 and password != password_new; declare continue handler for not found SET all_done = 1; SET all_done = 0; SET result = 0; OPEN cur_1; FETCH cur_1 into this_password; IF all_done != 1 THEN UPDATE users SET password_previous = this_password, password = password_new, reset_password = 0, password_change_time = now() WHERE id = user_id1; SET result = 1; END IF; END ; //
Clean_sessions
We want to clear out the session table, so we:
- SELECT all sessions where the last_access time is less than the current time subtract the maximum session time (ie, timeout).
- For each user, find the latest one in this list, and then update the user's last_access time.
- DELETE these old sessions from the session table
In this example, I have used a timeout of 600 seconds. However, it is feasable to have a table called, for example, Variables (name text, value text), and insert into Variables VALUES ('Web_Session_Timeout', 600), and use this value instead!
create procedure clean_sessions (OUT result int unsigned) language SQL comment 'Returns the number of sessions cleaned' BEGIN declare this_cookie varchar(36); declare this_user_id, this_duration int unsigned; declare this_access_time, this_session_start_time datetime; declare all_done int(1); declare c1 CURSOR FOR SELECT user_id,cookie,session_start_time,last_access, last_access-session_start_time from sessions WHERE last_access < now()-INTERVAL (select value from variable where name='idle_session_timeout_seconds') SECOND; declare continue handler for not found SET all_done = 1; OPEN c1; SET result = 0; SET all_done = 0; REPEAT FETCH c1 into this_user_id, this_cookie, this_session_start_time, this_access_time, this_duration; IF all_done != 1 THEN UPDATE users SET last_access = this_access_time WHERE id = this_user_id AND last_access < this_access_time; UPDATE logins SET duration = this_duration WHERE user_id = this_user_id AND login_time = this_session_start_time and duration is NULL LIMIT 1; DELETE FROM sessions where cookie = this_cookie; SET result = result+1; END IF; UNTIL all_done = 1 END REPEAT; CLOSE c1; END ; //
Using this from Perl
Pretty easy.
use DBI; use CGI; my $dbh = DBI->connect(..); my $query = CGI->new; if (defined $query->cookie(-name => session) { my $sql = "call cookie_session(?, @user_id); select @user_id as user_id"; my $sth = $dbh->prepare($sql); my $res = $sth->execute($query->cookie(-name => session)); my $ref = $sth->fetchrow_hashref; # If we have a user ID, then this is a valid user session } elsif (defined $query->param('email')) { my $sql = "call login(?, ?, ?, @cookie, @user_id); select @cookie as cookie, @user_id as user_id"; my $sth = $dbh->prepare($sql); my $res = $sth->execute($query->param('email'), $query->param('password'), $query->remote_host); my $ref = $sth->fetchrow_hashref; my $cookie_val = $ref->{cookie}; # Now set this in the browser... }
Get the SQL for this websessions.sql.