Authorization and Sessions - Security and the Real World - PHP and MySQL: The Missing Manual (2011)

PHP and MySQL: The Missing Manual (2011)

Part 4. Security and the Real World

Chapter 13. Authorization and Sessions

You have two big tasks left. Well, one and a half, as some of this work you’ve already done:

§ Two levels of authentication: one to get to the main application, and then admin-level authentication to get to a page like show_users.php and delete_user.php.

§ Some basic navigation—and that navigation should change based on a user’s login and the groups to which they belong.

You’ve already got blanket authentication on that first item. That’s handled through authorize.php. But now it’s time to go further: authorize.php needs to be improved. It should take in a group (or, better, a list of groups) and only allow access if the user is in the passed in groups.

And on the second item, you’ve also got some work done. You’ve got menus that changed based on whether a user is logged in or not. Again, though, there are some needed improvements: if a user is in certain groups, they should see an option to administrate users, and get a link toshow_users.php (in addition to the standard link to show_user.php).

And then…there’s a problem with cookies. It’s not a huge problem, but there are some very real concerns over a high-end application using cookies and only cookies for authentication. So there’s that to consider.

Time’s a wasting. You’ve got problems to fix.

Modeling Groups in Your Database

First things first. Before you can look up the groups to which a user belongs, you’ve got to have some groups in your database. That, of course, means you need a table to store groups, and some means by which you can connect a user to a group. Not only that, you need to be able to connect one user to multiple groups.

So there are a few distinct steps here:

1. Create a table in the database to store groups.

2. Map a user to zero, one, or more groups.

3. Build PHP to look up that mapping.

4. Restrict pages based on any login, or a particular set of groups.

First things first: it all begins with a database table.

Adding a Groups Table

Creating a new table is a trifling thing for a PHP and MySQL programmer. You can easily create a new table, name it, give MySQL a few columns, specify which are NOT NULL, and you’re quickly past database table creation.

mysql> CREATE TABLE groups (

-> id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,

-> name VARCHAR(30) NOT NULL,

-> description VARCHAR(200)

-> );

Query OK, 0 rows affected (0.03 sec)

mysql> DESCRIBE groups;

+-------------+--------------+------+-----+---------+----------------+

| Field | Type | Null | Key | Default | Extra |

+-------------+--------------+------+-----+---------+----------------+

| id | int(11) | NO | PRI | NULL | auto_increment |

| name | varchar(30) | NO | | NULL | |

| description | varchar(200) | YES | | NULL | |

+-------------+--------------+------+-----+---------+----------------+

3 rows in set (0.03 sec)

As usual, each group needs an ID and a name. The description column is optional—it’s not NOT NULL, which is bad grammar but good database design—and that’s all you need.

It’s hard to do much testing without some actual group information, so go ahead and add a few groups into your new groups table:

mysql> INSERT INTO groups

-> (name, description)

-> VALUES ("Administrators",

-> "Users who administrate the entire application.");

Query OK, 1 row affected (0.04 sec)

mysql> INSERT INTO groups

-> (name, description)

-> VALUES ("Luthiers",

-> "Guitar builders. They make the instrument that make the mu-

sic.");

Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO groups

-> (name, description)

-> VALUES ("Musicians",

-> "Play what you feel, they say. And they feel it.");

Query OK, 1 row affected (0.00 sec)

NOTE

Create whatever groups you want for your own users. Just make sure you create an Administrators group. If you call that group something else, you’ll want to change the later references in this chapter from “Administrators” to whatever name you use.

As usual, test before moving on:

mysql> SELECT id, name FROM groups;

+----+----------------+

| id | name |

+----+----------------+

| 1 | Administrators |

| 2 | Luthiers |

| 3 | Musicians |

+----+----------------+

3 rows in set (0.01 sec)

The Many-to-Many Relationship

How do you connect users to groups? Before you can start worrying about SQL, you need to think clearly about how these two tables are related. Relationships help you determine in what manner tables are linked.

One-to-One, One-to-Many, Many-to-Many

You’ve already seen an example of a one-to-one relationship. When you were storing actual images in your database, you had a single entry in users that was related to a single entry in images. So there was a one-to-one relationship between users and images.

With groups, that’s not the case. You’ve already seen that a single user can be in zero groups, or one group, or many groups. For example, Michael Greenfield can be a luthier, a musician, and an administrator. And you might have another user who is in none of those groups.

So from that perspective, you have a one-to-many relationship. One user can be related to many groups. Many here doesn’t have a strict literal meaning, either. It means something more like “as many as you want.” So “many” can mean 0 or 1 or 1000, or anything in between or above.

However, that’s only part of the story. Because you can flip things around, and look at it from the groups point of view. A group can have many users. So the Administrators group might have four, five, or twenty users. That means there’s a one-to-many relationship on the groups-to-users side of things, as well as on the users-to-groups side.

What you have here is a many-to-many relationship between users and groups (or, if your like, between groups and users). One user can be in many groups; one group can have many users. It’s a multi-sided relationship, which is a bit more complex to model at the database level, but just as important in the real world of data as a one-to-one relationship or a one-to-many relationship.

NOTE

A good example of a true one-to-many relationship is a user that might have a gallery of images. A user can have many images, but that user’s images can’t be related to multiple users. It really is a one (user)-to-many (images) relationship.

POWER USERS’ CLINIC: LOTS OF PROGRAMMERS ARE SECRETLY MATH GEEKS

It’s true: most programmers have at least a little love for math, often buried somewhere deep down. One proof of that is that many programming concepts share naming ideas from math.

So you may hear about one-to-one (1-to-1 or, sometimes, 1:1) relationships. And you’ll also hear about one-to-many relationships. But just as often, you’ll hear about a 1-to-N relationship. N is a mathematical term; it’s usually written as lowercase n in math, but it’s more often capital N in programming. And that N is just a stand-in for a variable number. So N could be 0, or 1, or some really large number.

In that light, then, a one-to-many relationship is the same as a 1:N relationship. It’s just that 1:N is a shorter, more concise way to say the same thing. By now, you know programmers—like you—tend to favor short and concise. So on database diagrams you’ll often see 1:N, which just tells you that you’ve got a oneto-many relationship occurring between two tables.

And then, of course, you’ve got N:N, which is just saying that many items in one table are related to many items in another. That said, an N:N relationship (and the many-to-many relationship that it represents) is really a conceptual or virtual idea. It takes two relationships at the database level in most systems to model an N:N relationship, as you’ll see on the next page.

Joins are Best Done with IDs

When you related a user to a profile image, you used an ID. So an image had its own ID, uniquely identifying the image. But it also had a user_id, which connected the image to a particular user in the users table. That made it easy to grab an image for a user with something like this:

SELECT *

FROM images

WHERE user_id = $user_id;

Or, you can join the two tables like this:

SELECT u.username, u.first_name, u.last_name, i.filename, i.image_data

FROM users u, images i

WHERE u.id = i.user_id;

In both cases, it’s the IDs that are the connectors. This works fine in a one-to-one relationship, as it does in a one-to-many relationship. The “many” side just adds a column that references the ID of the “one” side. So many images all have a user_id column that references a user with the ID 51 (or 2931 or whatever else you have in users).

But with users and groups, you don’t have a one-to-one or a one-to-many. You have a many-to-many. How do you handle that?

Using a Join Table to Connect Users with Groups

IDs are the key. And it’s pretty simple to model a one-to-many relationship. The trick is modeling a many-to-many relationship, because connecting the IDs is a problem. You need a sort of matrix: a set of user IDs and group IDs that are connected.

Think about the many-to-many relationship. In its simplest form, it’s really two one-to-many relationships; that’s how you worked out that users and groups were related via a many-to-many relationship. You started with one side: users. Then you figured out it was one-to-many. Then, the other side: groups. Also one-to-many.

In the same way, you can construct a many-to-many relationship at the database level this way. You have a table like users that connects to an intermediary table. Call it user_groups, and suppose it has a user_id and a group_id. So a user_id might appear in two rows: in the first row along with the ID for the “Administrators” group, and again with the ID of the “Musicians” group. That gives you the one-to-many from users to groups.

But then you also have the one-to-many from groups to users. The ID for “Administrators” might appear in five different rows within user_groups, once for each of the five users to which that group relates.

Create this table to give this idea a concrete form:

mysql> CREATE TABLE user_groups (

-> user_id INT NOT NULL,

-> group_id INT NOT NULL

-> );

Query OK, 0 rows affected (0.03 sec)

This table becomes a bridge: each row connects one user to one group. So for “Jeff Traugott” with an ID of 51, and a group “Luthiers” with an ID of 2, you’d add this row to user_groups.

mysql> INSERT INTO user_groups

-> (user_id, group_id)

-> VALUES (51, 2);

Query OK, 1 row affected (0.02 sec)

mysql> select * from user_groups;

+---------+----------+

| user_id | group_id |

+---------+----------+

| 51 | 2 |

+---------+----------+

1 row in set (0.00 sec)

On their own, the users and groups tables aren’t connected. But this additional table establishes the many-to-many relationship.

Testing Out Group Membership

So to see if a user is in a group, you need to see if there’s an entry with both the user’s ID you want, and the group’s ID you want, in user_groups.

mysql> SELECT COUNT(*)

-> FROM users u, groups g, user_groups ug

-> WHERE u.username = "traugott"

-> AND g.name = "Luthiers"

-> AND u.user_id = ug.user_id

-> AND g.id = ug.group_id;

+----------+

| COUNT(*) |

+----------+

| 1 |

+----------+

1 row in set (0.00 sec)

Bingo! Walk through this query slowly; it looks a little complex at first blush, but it’s straightforward.

First, you can use COUNT(*) to return a count on the rows returned from the query. And then there are the three tables you’ll need to involve: users, groups, and user_groups.

SELECT COUNT(*)

FROM users u, groups g, user_groups ug

Then, you indicate the name of the user you want (using any column you want; first name, or last name, or username), and the name of the group you want. This will cause exactly one (or zero, if there’s no match) row in both users and groups to be isolated.

SELECT COUNT(*)

FROM users u, groups g, user_groups ug

WHERE u.username = "traugott"

AND g.name = "Luthiers"

Now, you need to connect those individual rows—each with an ID—to user_groups. This is just a regular join. You use the IDs in each table to match up with the ID columns in user_groups:

SELECT COUNT(*)

FROM users u, groups g, user_groups ug

WHERE u.username = "traugott"

AND g.name = "Luthiers"

AND u.user_id = ug.user_id

AND g.id = ug.group_id;

So this connects zero or one row in users to user_groups, which is also connected to zero or one row in groups. The result? Either a single row with a COUNT value of 1, meaning that there’s a connection from a user in users to the group in groups you indicated…

+----------+

| COUNT(*) |

+----------+

| 1 |

+----------+

…or a row with a COUNT value of 0, meaning there’s no connection:

mysql> SELECT COUNT(*)

-> FROM users u, groups g, user_groups ug

-> WHERE u.username = "traugott"

-> AND g.name = "Administrators"

-> AND u.user_id = ug.user_id

-> AND g.id = ug.group_id;

+----------+

| COUNT(*) |

+----------+

| 0 |

+----------+

1 row in set (0.05 sec)

WARNING

Watch out! With this particular expression—using COUNT—you do get a single row each time. The important information is the value in the row, not that there is a row.

Now you just have to turn this into PHP code.

Checking for Group Membership

You’ve already got the makings of a good authentication scheme. You’ve replaced basic authentication with your own authentication scheme. And that’s authentication: allowing a user in if she logs in. They authenticate in some manner that tells your system that the user really is who she says she is.

But now it’s time to add authorization: the ability to only give access to certain pages based on more specific criteria. At its simplest, you do have some level of authorization through authorize.php: you only authorize users who are authenticated. But usually authorization goes a lot further than that. It’s more granular; you can control access based on, say, group membership.

At this point, you’ve got the users. You’ve got the groups. You’ve got the connection between the two. So now authorize.php needs to be enhanced to work these groups into your authorization scheme.

authorize.php Needs a Function

Right now, authorize.php runs automatically when it’s required by a script. So the code in authorize.php isn’t in a function; it’s just dropped into the body of the PHP file:

<?php

if ((!isset($_COOKIE['user_id'])) || (!strlen($_COOKIE['user_id']) > 0)) {

header('Location: signin.php?' .

'error_message=You must login to see this page.');

exit;

}

?>

That’s worked fine up until now. But the times, they are a’ changin’. You need a means by which you can pass a group—or a list of groups—to authorize.php, and then authorize.php has to run through those groups and see if there’s a connection with the current user. That situation—a block of code that should take in a piece of information with which to work—screams “function.” There are some other options, but they’re less easy to understand and maintain. (If you’re curious about those options, see the box on On Functions and Non-Functions.)

Go ahead and create a new function in authorize.php. Eventually, it should take an array of groups that allow access. For now, you can set a default value for the parameter the function takes and use that default value to keep the current functionality: allowing access to any authorized user.

<?php

function authorize_user($groups = NULL) {

// No need to check groups if there aren't cookies set

if ((!isset($_COOKIE['user_id'])) ||

(!strlen($_COOKIE['user_id']) > 0)) {

header('Location: signin.php?' .

'error_message=You must login to see this page.');

exit;

}

}

?>

Now jump back into show_user.php, and add an explicit call to this function. You don’t need to pass in any group names. show_user.php should be open to any logged-in user.

<?php

require '../scripts/authorize.php';

require '../scripts/database_connection.php';

require '../scripts/view.php';

// Authorize any user, as long as they're logged in

authorize_user();

// Get the user ID of the user to show

$user_id = $_REQUEST['user_id'];

// Build the SELECT statement

// and so on...

You should test this out. Since the default functionality should be just what you already have, make sure you can’t access show_user.php without logging in first. Enter the URL into your browser, and you should see your sign-in page, as shown in Figure 13-1.

One of the first steps to any new bit of functionality is to make sure that old functionality still works. It’s no good to code for an hour or two adding new features if you end up breaking all the old features in the process. Test the new stuff, but start by testing the old stuff first.

Figure 13-1. One of the first steps to any new bit of functionality is to make sure that old functionality still works. It’s no good to code for an hour or two adding new features if you end up breaking all the old features in the process. Test the new stuff, but start by testing the old stuff first.

Taking in a List of Groups

Now it’s time to get to the point of all this work. Start out by sending a list of groups—through a PHP array—to authorize_user. You can do this in show_users.php and delete_user.php, both of which should require the Administrators group for access.

<?php

require_once '../scripts/app_config.php';

require_once '../scripts/authorize.php';

require_once '../scripts/database_connection.php';

require_once '../scripts/view.php';

// Only Administrators can access this page

authorize_user(array("Administrators"));

// Rest of the PHP code and HTML output

NOTE

The change above is shown in show_users.php. Make the same change in delete_user.php so that it can’t be directly accessed.

DESIGN TIME: ON FUNCTIONS AND NON-FUNCTIONS

In authorize.php, you’ve got a function that takes in zero or more groups via a parameter. But that’s just one way to handle the issue. There are other approaches: you could, for example, set a variable and then use that variable in the required file.

So, for example, take a simple script like this:

<?php

$message = "hello\n\n";

require_once "print.php";

?>

You can call this script test.php if you’re following along. And then suppose print.php, the references script, looks like this:

<?php

echo $message;

?>

When print.php is required, it’s like the code in print.php is inserted right in the place of the require_once line. That means that when you run this script, PHP essentially sees this:

<?php

$message = "hello\n\n";

echo $message;

?>

Run test.php, and you’d get this result:

yellowta@yellowtagmedia.com [~/www/phpMM/

ch13]# php test.php

X-Powered-By: PHP/5.2.17

Content-type: text/html

hello

So you can “pass” information into a required script in this manner.

The problem with this approach isn’t that it’s not easy to implement, or that it has problems in implementation. The problem is that’s it’s just not that clear.

Here’s what the code would look like for authorization:

$allowed_groups = array("Musicians", "Lu-

thiers");

require_once "../scripts/authorize.php";

There’s nothing wrong here. It’s simply unclear that the $groups variable is required before the require_once to authorize.php, and that in fact authorize.php makes use of that variable. So while an authorize_user function is a bit clumsy, it’s clear and better than the alternative: code that’s a little difficult to read unless you already know what it does.

Using an array is about the simplest means in PHP of getting a list to a function. Now, in authorize.php, you’re getting either nothing, or a list of allowed group names. So you can start to do some work with those groups.

First, though, if the parameter passed to authorize_user is either an empty list or NULL, you should have the function bail out. No need to do any database searching in those two cases.

<?php

function authorize_user($groups = NULL) {

// No need to check groups if there aren't cookies set

if ((!isset($_COOKIE['user_id'])) ||

(!strlen($_COOKIE['user_id']) > 0)) {

header('Location: signin.php?' .

'error_message=You must login to see this page.');

exit;

}

// If no groups passed in, the authorization above is enough

if ((is_null($groups)) || (empty($groups))) {

return;

}

}

?>

NOTE

The empty function takes just about any PHP type and figures out what “empty” means, and returns either true or false. For an array, empty returns true if there aren’t any items in the array.

Telling PHP to return causes control to return to the calling script. That’s allowing the script to run, which means letting the user see the page they requested.

Iterating Over Each Group

Now, back to the case where you do get a list of groups, as in show_users.php and delete_user.php. In those cases, authorize.php should loop over each group, and for each group, build a SQL query.

Start out by just looping over the $groups array. You can use a for loop, but in this case, there’s a better choice: foreach. foreach lets you loop over an array and automatically assign a variable to the current item in the array:

$my_array = array("first", "second", "third");

foreach ($my_array as $item) {

echo $item;

}

So for $groups, you could set the loop up like this:

foreach ($groups as $group) {

// do a SQL search for the current $group

}

Now, think through what happens inside the loop. You want something similar to the original SQL you used to connect users to groups:

SELECT COUNT(*)

FROM users u, groups g, user_groups ug

WHERE u.username = "traugott"

AND g.name = "Luthiers"

AND u.user_id = ug.user_id

AND g.id = ug.group_id;

But this code is more complex than what you need in authorize.php (Abstracting What’s the Same). First, you don’t need the users table at all. That table is only part of the query to connect a username to a user_id. But your app already has the user’s user_id. So things simplify to this:

SELECT COUNT(*)

FROM user_groups ug, groups g

WHERE g.name = mysql_real_escape_string($group)

AND g.id = ug.group_id

AND ug.user_id = mysql_real_escape_string($_COOKIE['user_id']);

NOTE

As usual, you’ll want to use mysql_real_escape_string to make sure your database gets clean values. In fact, you might as well get into the habit: just use mysql_real_escape_string on pretty much anything that originates in your scripts and is sent to MySQL.

There’s another improvement you can make, too. In the query above, you’d need to get the result row, and see if the value is 0 (no membership) or 1 (membership). But that’s an additional step. What would be even better is to just check and see if there’s a result at all. In other words, you want a query that only returns a result row if there’s a match. So you can make another change:

SELECT ug.user_id

FROM user_groups ug, groups g

WHERE g.name = mysql_real_escape_string($group)

AND g.id = ug.group_id

AND ug.user_id = mysql_real_escape_string($_COOKIE['user_id']);

The particular column you select from user_groups doesn’t matter; you could use ug.group_id as well. The point is that now, you either get a result when there’s a match, or you get no result, so that’s one less step your code needs to take.

Put all this together, and you end up with something like the following in your foreach loop:

foreach ($groups as $group) {

// do a SQL search for the current $group

$query = "SELECT ug.user_id" .

" FROM user_groups ug, groups g" .

" WHERE g.name = '" . mysql_real_escape_string($group) . "'" .

" AND g.id = ug.group_id" .

" AND ug.user_id = " .

mysql_real_escape_string($COOKIE['user_id']) . "';";

mysql_query($query);

// Deal with results

}

This script works, and it doesn’t require the users table. But you’re constructing this string, over and over again. For every group, this string is recreated and that’s wasteful.

Here’s where sprintf becomes your friend (again). With sprintf, you can construct a single string, give it an escape character or two, and simply insert into the string values for each escape character. The string says unchanged, and you’re only modifying the data within that string that’s variable.

So you can construct the query string outside of the foreach, like this:

// Set up the query string

$query_string =

"SELECT ug.user_id" .

" FROM user_groups ug, groups g" .

" WHERE g.name = '%s'" .

" AND g.id = ug.group_id" .

" AND ug.user_id = " . mysql_real_escape_string($_COOKIE['user_id']);

foreach ($groups as $group) {

// do a SQL search for the current $group

// Deal with results

}

Then, within the foreach, use sprintf to supply the values to drop into the string for a particular group:

// Set up the query string

$query_string =

"SELECT ug.user_id" .

" FROM user_groups ug, groups g" .

" WHERE g.name = '%s'" .

" AND g.id = ug.group_id" .

" AND ug.user_id = " . mysql_real_escape_string($_COOKIE['user_id']);

foreach ($groups as $group) {

// do a SQL search for the current $group

$query = sprintf($query_string, mysql_real_escape_string($group));

$result = mysql_query($query);

// Deal with results

}

Note that in addition to using sprintf, this code assigns the current user ID—from $_COOKIE—to the string assembled outside of the loop. There’s no need to feed that to sprintf, because it won’t change as you loop.

FREQUENTLY ASKED QUESTION: WOULDN’T ALL THOSE QUERIES WORK?

Absolutely. As you’ve come to realize, though, there are solutions to problems, and then they are better solutions to problems. When you’re working with databases, “better” usually means “faster,” and “faster” usually means “less work for the database to do.”

In the case of looking up a group and seeing if a user is a member, there’s nothing functionally wrong with this query:

SELECT COUNT(*)

FROM users u, groups g, user_groups ug

WHERE u.username =

mysql_real_escape_string($_

COOKIE['username'])

AND g.name = mysql_real_escape_

string($group)

AND u.user_id = ug.user_id

AND g.id = ug.group_id;

But you’re doing a lot more work than you need. There’s an entire extra table involved (users) that you can cut out, because you already have the user’s ID in a cookie.

You can cut down on dealing with results by moving from a COUNT in the SELECT—which will require you to always examine the results in a row—for a column in user_groups. Then you only need to see if there are rows returned; the values in those result rows become irrelevant.

And you can improve on general execution time by only creating a string once, and using sprintf to modify just a small part of that string every time you go to a new group. Again, this is a small improvement, but an important one that’s easy to make.

All these small changes can add up to noticeable improvements in your app. It will simply “feel” more responsive. This is even more important because the authorization script is going to run every time a user hits your page. That means that a script that’s sloppy or slower than it needs to be creates a lag in every single page access.

Most users don’t like—and many won’t put up with—slow-loading sites. This isn’t a pause while you secure your user concert tickets or look up shipping information. It’s simply them navigating to a new page. A little work on your script to keep things peppy makes a huge difference on their experience, especially as you have more and more users accessing your site, which means more and more hits against your database to verify group membership.

Allow, Deny, Redirect

With a solid query in place, it’s time to deal with the results. You can check the number of rows, and know all you need: if no rows were returned, the user isn’t a member of the group indicated by $group, and your loop should continue, going to the next $group in $groups.

If there is a row returned from a query, not only is the user in an allowed group, but authorize_user needs to stop. No need to keep looping over $groups; just return control to the calling script, so the PHP and HTML of that script can take over.

And then, the final case: all the groups have been checked, and there’s never been a result row. That’s the case when the foreach loop ends. If this is the case, it’s not okay to send control back to the calling script, because that would be letting the user “in,” and that’s exactly the opposite of what should happen. It’s also not appropriate to redirect the user back to the sign-in page. They are signed in, at least in most cases, and simply don’t have the right level of permissions to access the current page.

So what’s left? In the simplest case, just use handle_error one more time. You might want to build this out yourself, though. Perhaps you could redirect them to the last page they viewed and set an error message. Or you could build a customized page to allow the user to request permissions for a certain page. No matter how you cut it, though, you’re going to be sending them somewhere else; the current page is never shown.

Here’s a version of authorize.php that takes all this into account:

<?php

require_once 'database_connection.php';

require_once 'app_config.php';

function authorize_user($groups = NULL) {

// No need to check groups if there aren't cookies set

if ((!isset($_COOKIE['user_id'])) || (!strlen($_COOKIE['user_id']) > 0)) {

header('Location: signin.php?' .

'error_message=You must login to see this page.');

exit;

}

// If no groups passed in, the authorization above is enough

if ((is_null($groups)) || (empty($groups))) {

return;

}

// Set up the query string

$query_string =

"SELECT ug.user_id" .

" FROM user_groups ug, groups g" .

" WHERE g.name = '%s'" .

" AND g.id = ug.group_id" .

" AND ug.user_id = " . mysql_real_escape_string($_COOKIE['user_id']);

// Run through each group and check membership

foreach ($groups as $group) {

$query = sprintf($query_string, mysql_real_escape_string($group));

$result = mysql_query($query);

if (mysql_num_rows($result) == 1) {

// If we got a result, the user should be allowed access

// Just return so the script will continue to run

return;

}

}

// If we got here, no matches were found for any group

// The user isn't allowed access

handle_error("You are not authorized to see this page.");

exit;

}

?>

It’s been a long time coming, but you can finally try this out. Make sure you’ve got a user in users that is a member of Administrators (through user_groups), and one that’s not. The former should be able to navigate to show_users.php without any problems; the latter should be kicked to the error page, as shown in Figure 13-2.

You should see this page as a first step toward authorization, rather than a last one. This setup is a bit clumsy, and you should come up with a better, less interruptive way to let users know they’ve ended up somewhere they shouldn’t be. Take them back to a page they can access, if possible. Full-page errors should be serious things, rarely shown without a lot of thought.

Figure 13-2. You should see this page as a first step toward authorization, rather than a last one. This setup is a bit clumsy, and you should come up with a better, less interruptive way to let users know they’ve ended up somewhere they shouldn’t be. Take them back to a page they can access, if possible. Full-page errors should be serious things, rarely shown without a lot of thought.

Group-Specific Menus

Right now, you can use authorize_user to check a user against a list of groups, and either reject access to a page, or allow the user to see a page. That means you have the logic to handle group-specific menus, but the actual implementation may take a bit of refactoring.

Take a look at your menu system as it stands, in view.php:

function display_title($title, $success_message = NULL, $error_message = NULL)

{

echo <<<EOD

<body>

<div id="page_start">

<div id="header"><h1>PHP & MySQL: The Missing Manual</h1></div>

<div id="example">$title</div>

<div id="menu">

<ul>

<li><a href="index.html">Home</a></li>

EOD;

if (isset($_COOKIE['user_id'])) {

echo "<li><a href='show_user.php'>My Profile</a></li>";

echo "<li><a href='signout.php'>Sign Out</a></li>";

} else {

echo "<li><a href='signin.php'>Sign In</a></li>";

}

echo <<<EOD

</ul>

</div>

EOD;

display_messages($success_message, $error_message);

echo "</div> <!-- page_start -->";

}

authorize_user isn’t a function you can just drop in here; it either allows a user to see a page, or disallows him. It’s not a fine-grained tool with which you can check group membership and get back a true or false value.

What you really want is something like the following:

function display_title($title, $success_message = NULL, $error_message = NULL)

{

echo <<<EOD

<body>

<div id="page_start">

<div id="header"><h1>PHP & MySQL: The Missing Manual</h1></div>

<div id="example">$title</div>

<div id="menu">

<ul>

<li><a href="index.html">Home</a></li>

EOD;

if (isset($_COOKIE['user_id'])) {

echo "<li><a href='show_user.php'>My Profile</a></li>";

if (user_in_group($_COOKIE['user_id'], "Administrators")) {

echo "<li><a href='show_users.php'>Manage Users</a></li>";

}

echo "<li><a href='signout.php'>Sign Out</a></li>";

} else {

echo "<li><a href='signin.php'>Sign In</a></li>";

}

echo <<<EOD

</ul>

</div>

EOD;

display_messages($success_message, $error_message);

echo "</div> <!-- page_start -->";

}

NOTE

You’ll also need to add a require_once for authorize.php to view.php for this code to eventually work.

Then, that function would check group memberships, and show the Manage Users link to Administrators. You’ve already got all the relevant code in authorize_user.php:

// Set up the query string

$query_string =

"SELECT ug.user_id" .

" FROM user_groups ug, groups g" .

" WHERE g.name = '%s'" .

" AND g.id = ug.group_id" .

" AND ug.user_id = " . mysql_real_escape_string($_COOKIE['user_id']);

// Run through each group and check membership

foreach ($groups as $group) {

$query = sprintf($query_string, mysql_real_escape_string($group));

$result = mysql_query($query);

if (mysql_num_rows($result) == 1) {

// If we got a result, the user should be allowed access

// Just return so the script will continue to run

return;

}

}

It just needs to be adapted into a new function that takes in a user’s ID and a group. That’s pretty easy, though:

function user_in_group($user_id, $group) {

$query_string =

"SELECT ug.user_id" .

" FROM user_groups ug, groups g" .

" WHERE g.name = '%s'" .

" AND g.id = ug.group_id" .

" AND ug.user_id = %d";

$query = sprintf($query_string, mysql_real_escape_string($group),

mysql_real_escape_string($user_id));

$result = mysql_query($query);

if (mysql_num_rows($result) == 1) {

return true;

} else {

return false;

}

}

Nothing here is new. It’s just a new riff on an old hit: the code you’ve already got in authorize.php, in the authorize_user function.

Get this code in place, and then try it out. First, log in as a user that’s not in Administrators. Visit a page like show_user.php, and your menu options should not have a Manage Users options (see Figure 13-3).

Ahh, the poor user who isn’t a member of Administrators. They see no Manage Users option—but that’s actually good. They don’t see options that they can’t access, and never feel poor at all. That’s the heart of good authorization: as important as it is to “keep people out,” it’s equally important to just avoid showing options that aren’t accessible.

Figure 13-3. Ahh, the poor user who isn’t a member of Administrators. They see no Manage Users option—but that’s actually good. They don’t see options that they can’t access, and never feel poor at all. That’s the heart of good authorization: as important as it is to “keep people out,” it’s equally important to just avoid showing options that aren’t accessible.

Now sign out, and do exactly the same thing again, this time with an administrative user. Magically—at least from the non-PHP programmer’s point of view—a new menu option appears. You can see the Manage Users link in Figure 13-4.

For the admin, they simply “get” additional options. Manage Users is available only because it’s available. One thing to think about, though: you’re repeating the “Administrators” group in several places. You might want to think about a constant, or even an is_admin function to make remembering how to spell “Administrators” unnecessary.

Figure 13-4. For the admin, they simply “get” additional options. Manage Users is available only because it’s available. One thing to think about, though: you’re repeating the “Administrators” group in several places. You might want to think about a constant, or even an is_admin function to make remembering how to spell “Administrators” unnecessary.

Entering Browser Sessions

So far, cookies have been the secret to much of your authentication and authorization success. But there are many programmers who really, really hate a cookie-only solution to storing a user’s information. The biggest issue with cookies is that they are entirely client-side. That means that anything you store in a cookie lives in that cookie, on the client’s computer.

In your case, that means the user’s ID and username are stored on your computer. In fact, on most web browsers, you can easily look at your cookies. Log in to your app, and then view your cookies. On Firefox, for example, you can click Tools→Web Developer→Cookies→View Cookie Information. Figure 13-5 shows the cookies related to this app.

FREQUENTLY ASKED QUESTION: SHOULDN’T AUTHORIZE_USER CALL USER_IN_GROUP?

Major refactoring points if you thought of this question, or if it felt a bit like you might be duplicating code in user_in_group, and that bothered you. It’s true; there’s a lot similar about the code in user_in_group and the code that iterates over $groups and looks up each group within authorize_user.

One way to take advantage of user_in_group and remove this similar code would be to rework the foreach in authorize_user:

// Remove the initial query string

// before the loop

// Run through each group and

// check membership

foreach ($groups as $group) {

if (user_in_group($_COOKIE['user_id'],

$group) {

// Just return so the script

// will continue to run

return;

}

}

This looks pretty good. There’s a lot less code, and you’ve done some pretty nice refactoring.

But, you’ve actually gone back toward the original code in authorize_user from which you moved away. Now there’s a query string created every time through the loop (hidden away within user_in_group). That string is being created over and over, and continually assigned the same user ID with each group in $groups. By moving away from that approach, you (if only in some small ways) sped up the performance of authorize_user.

And here’s where you have to make a tough decision. Is the clean, refactored approach here worth the loss in speed that requires some nearly duplicate code? In the case of a bit of code that’s potentially called on most, if not every page—authorize_user—it might be worth not refactoring. That little bit of improved speed times one hundred page views; one thousand; one million…it starts to seriously add up.

NOTE

On Safari, cookies are under Preferences. Click the Privacy tab, and then the Details button. With Chrome, select Preferences, then Under the Hood, Content Settings and then All Cookies and Site Data In Internet Explorer, select View→Internet Options, click the General tab, and select “Settings” under Browser History. Then you can select View Files under “Temporary Internet Files and History Settings.” All these options get you the same information, although in each case it looks a bit different.

This client-side storage is the main reason some developers don’t like cookies. Whether the client computer is a public machine in a library, or a home machine, there’s just something that seems pretty unsafe about leaving what amounts to a system-level value like a user ID on any old computer.

And that’s an important issue. That user ID uniquely identifies a user in your database. On top of that, most applications that use cookies add additional information to a client’s machine, rather than lessening it. You might speed up user and group searches by storing cookies with the user’s groups (or the IDs of those groups) in cookies; you might store personal information you don’t want to constantly look up in cookies.

You can see the user_id and the username cookies, as well (in most cases) as several others, usually related to codes the browser uses for keeping up with your app’s cookies. This is raw data, stored on every client’s personal computer.

Figure 13-5. You can see the user_id and the username cookies, as well (in most cases) as several others, usually related to codes the browser uses for keeping up with your app’s cookies. This is raw data, stored on every client’s personal computer.

And all this information ends up living on your users’ computers until those cookies expire. So what’s a security conscious programmer to do?

Sessions are Server-Side

Sessions are generally considered the “answer” to cookies. Sessions are very similar to cookies in that they can store information. However, sessions have two big differences:

1. Sessions are stored on the server, rather than the client. You can’t view session data from a browser because there’s nothing to view, except perhaps a non-readable ID that connects a particular browser with a session.

2. Because sessions are stored on the server, they can be used to store much bigger chunks of data than cookies. You could store a user’s profile picture on the server in a session, for example, and not worry about taking up space on a user’s computer.

Because they don’t store potentially sensitive information on the user’s computer, a lot of programmers prefer sessions.

Sessions Must Be Started

The biggest change in dealing with sessions isn’t lots of new syntax. In fact, you’ll quickly see that changing from using cookies to sessions is pretty simple. But there is one significant difference: before you can do any work with sessions, you have to call session_start:

// Start/resume sessions

session_start();

// Now do work with session information

So, you might naturally think: call session_start in signin.php, and you’re ready to roll. That’s exactly where you should first call session_start:

<?php

require_once '../scripts/database_connection.php';

require_once '../scripts/view.php';

$error_message = $_REQUEST['error_message'];

session_start();

// Rest of PHP and HTML...

Calling session_start here kicks off the PHP machinery that makes sessions available.

From $_COOKIE to $_SESSION

Now, it gets easy: instead of using the superglobal $_COOKIE, you use the super-global $_SESSION. Yes, it’s that simple:

<?php

require_once '../scripts/database_connection.php';

require_once '../scripts/view.php';

$error_message = $_REQUEST['error_message'];

session_start();

// If the user is logged in, the user_id in the session will be set

if (!isset($_SESSION['user_id'])) {

// and so on...

And then there’s one other small change. With sessions, you don’t use setcookie. Instead, you directly set values in $_SESSION, providing a key and a value:

if (!isset($_SESSION['user_id'])) {

// See if a login form was submitted with a username for login

if (isset($_POST['username'])) {

// Try and log the user in

$username = mysql_real_escape_string(trim($_REQUEST['username']));

$password = mysql_real_escape_string(trim($_REQUEST['password']));

// Look up the user

$query = sprintf("SELECT user_id, username FROM users " .

" WHERE username = '%s' AND " .

" password = '%s';",

$username, crypt($password, $username));

$results = mysql_query($query);

if (mysql_num_rows($results) == 1) {

$result = mysql_fetch_array($results);

$user_id = $result['user_id'];

// No more setcookie

$_SESSION['user_id'] = $user_id;

$_SESSION['username'] = $username;

header("Location: show_user.php");

exit();

} else {

// If user not found, issue an error

$error_message = "Your username/password combination was invalid.";

}

}

So now you use $_SESSION to retrieve values from the session, and $_SESSION to insert values into the session.

All the while, behind the scenes, all this information is stored on the server, rather than the client. Nice, right?

Sessions Must be Restarted, Too

But now there’s something a little strange. Try and sign in using a good username/password combination. You’re not going to see what you expect. Instead, you’ll get the error about not being logged in, shown in Figure 13-6. So what’s going on?

It looks like changing to sessions wasn’t quite as painless as it might have first appeared. Where is this error coming from? And does it mean that sessions don’t work?

Figure 13-6. It looks like changing to sessions wasn’t quite as painless as it might have first appeared. Where is this error coming from? And does it mean that sessions don’t work?

Think carefully; you may even want to search through signin.php. Is this an error related to sessions? Well, kind of. But it’s generated by show_user.php, not signin.php. In fact, it’s really an issue in authorize_user, which lives in authorize.php; that function is called at the beginning ofshow_user.php:

<?php

require '../scripts/authorize.php';

require '../scripts/database_connection.php';

require '../scripts/view.php';

// Authorize any user, as long as they're logged in

authorize_user();

But that makes perfect sense. authorize_user is expecting to find a user ID in $_COOKIE, which is exposed through $_REQUEST.

<?php

require_once 'database_connection.php';

require_once 'app_config.php';

function authorize_user($groups = NULL) {

// No need to check groups if there aren't cookies set

if ((!isset($_COOKIE['user_id'])) || (!strlen($_COOKIE['user_id']) > 0)) {

header('Location: signin.php?' .

'error_message=You must login to see this page.');

exit();

}

// And so on...

But now this is another easy change. $_COOKIE just has to go to $_SESSION:

<?php

require_once 'database_connection.php';

require_once 'app_config.php';

function authorize_user($groups = NULL) {

// No need to check groups if there aren't cookies set

if ((!isset($_SESSION['user_id'])) || (!strlen($_SESSION['user_id']) > 0)) {

header('Location: signin.php?' .

'error_message=You must login to see this page.');

exit();

}

// And so on...

Don’t forget to make a similar change later in the function, when the query string used for group searching is constructed:

// Set up the query string

$query_string =

"SELECT ug.user_id" .

" FROM user_groups ug, groups g" .

" WHERE g.name = '%s'" .

" AND g.id = ug.group_id" .

" AND ug.user_id = " . mysql_real_escape_string($_SESSION['user_id']);

This looks good. Unfortunately, you’re going to get the exact same result. Sign in again, and you’ll get Figure 13-7, yet another error. So now what’s going on now?

NOTE

You may get a different response, depending on your browser. You might see a timeout, or your browser may simply hang. In all these cases, it’s not good.

So now what’s going on? You changed out $_COOKIE for $_SESSION, but there’s still obviously a big problem here.

Figure 13-7. So now what’s going on? You changed out $_COOKIE for $_SESSION, but there’s still obviously a big problem here.

The secret is in the rather poorly named session_start function. That function sounds like it starts a new session. In that case, you should call it once—as you did—in signin.php. But PHP scripts each run on their own, without connection to any other script. So when show_user.php is called, it has no idea that a session was started back in signin.php.

In fact, there’s no connection at all between two scripts; they’re just two calls from a browser out there somewhere, hooked to the Internet with WiFi or an Ethernet cable. So how do two scripts—or a whole application’s worth of scripts—share this session data? The truth is a bit surprising: calling start_session actually creates a cookie on the client. Yes, you’re back to cookies!

But this cookie holds a fairly cryptic value (see Figure 13-8). This value refers to where a particular user’s data is stored on the server. It’s a way to say, “Look up this code in all the server’s session data. Whatever’s there…that’s mine.”

Yup. All this work to move off of cookies actually requires cookies. Still, you’re avoiding any valuable information being stored on the client. The unique key isn’t useful to anyone that doesn’t have access to your server, and that’s a good, secure thing.

Figure 13-8. Yup. All this work to move off of cookies actually requires cookies. Still, you’re avoiding any valuable information being stored on the client. The unique key isn’t useful to anyone that doesn’t have access to your server, and that’s a good, secure thing.

Accordingly, session_start does a lot more than start a one-time session. It looks up a user’s cookie, and if it’s there, restarts the session that ID references. So every script that wants to use $_SESSION has to call session_start.

That means fixing the problem in show_user.php involves two things: first, you need to call session_start in authorize.php, to ensure that session data is available to authorize_user and the other functions in authorize.php.

<?php

require_once 'database_connection.php';

require_once 'app_config.php';

session_start();

function authorize_user($groups = NULL) {

// an so on...

}

?>

Try this script out, and you’ll see an error pointing you to the second thing you’ve got to do. That error is a familiar one, shown in Figure 13-9.

You’ve seen this a few times, haven’t you? What’s going on in this particular case? For some reason, the code that looks up the user’s ID isn’t working, and kicking the user out with this error about their information not being found.

Figure 13-9. You’ve seen this a few times, haven’t you? What’s going on in this particular case? For some reason, the code that looks up the user’s ID isn’t working, and kicking the user out with this error about their information not being found.

$_REQUEST Doesn’t Include $_SESSION

Here’s the line in show_user.php that’s a problem:

// Get the user ID of the user to show

$user_id = $_REQUEST['user_id'];

This code worked because whether the user’s ID was in $_REQUEST, $_GET, $_POST, or $_COOKIE, it didn’t matter. All these bubble up to $_REQUEST. But now you’re passing the user ID in a different superglobal, one not included in $_REQUEST: $_SESSION.

Not only that, you still have code in show_users.php that does pass the user ID in a request parameter:

$user_row = sprintf(

"<li><a href='show_user.php?user_id=%d'>%s %s</a> " .

"(<a href='mailto:%s'>%s</a>) " .

"<a href='javascript:delete_user(%d);'><img " .

"class='delete_user' src='../images/delete.png' " .

"width='15' /></a></li>",

$user['user_id'], $user['first_name'], $user['last_name'],

$user['email'], $user['email'], $user['user_id']);

echo $user_row;

NOTE

This code is pretty deep into the middle of show_users.php. Look for the while loop within the HTML and you’ll find it.

So you can’t just switch $_REQUEST to $_SESSION and call it a day. Instead, you need to check both $_SESSION and $_REQUEST to cover all your bases:

<?php

require '../scripts/authorize.php';

require '../scripts/database_connection.php';

require '../scripts/view.php';

// Authorize any user, as long as they're logged in

authorize_user();

// Get the user ID of the user to show

$user_id = $_REQUEST['user_id'];

if (!isset($user_id)) {

$user_id = $_SESSION['user_id'];

}

// Look up user using $user_id

Now, if there’s no user ID found in $_REQUEST, the $_SESSION is checked. And then, last but not least, you need to call session_start before you can do any work with the session:

<?php

require '../scripts/authorize.php';

require '../scripts/database_connection.php';

require '../scripts/view.php';

session_start();

// Authorize any user, as long as they're logged in

authorize_user();

// Get the user ID of the user to show

$user_id = $_REQUEST['user_id'];

if (!isset($user_id)) {

$user_id = $_SESSION['user_id'];

}

// Look up user using $user_id

Now…finally…you can get back to viewing user profiles.

NOTE

You’re now calling session_start twice in the show_user.php flow: once in authorize.php, pulled in through require_once. Then, again, in the body of show_user.php.

Still, that extra call doesn’t do much beyond causing PHP to issue a notice, and there’s no guarantee that other scripts that bring in authorize.php will also call session_start. So the duplicate in show_user.php won’t always happen. It’s a better bet to treat each script as self-contained. Use session_start every time you’re working with sessions, even if it might have been called somewhere else.

Menu, Anyone?

All that’s left is the menu. That still uses $_COOKIE, but you know exactly what to do now. First, add the all-important call to session_start:

<?php

require_once 'app_config.php';

require_once 'authorize.php';

define("SUCCESS_MESSAGE", "success");

define("ERROR_MESSAGE", "error");

session_start();

// And then functions follow...

?>

Then, replace $_COOKIE with $_SESSION in display_title:

function display_title($title, $success_message = NULL, $error_message = NULL)

{

echo <<<EOD

<body>

<div id="page_start">

<div id="header"><h1>PHP & MySQL: The Missing Manual</h1></div>

<div id="example">$title</div>

<div id="menu">

<ul>

<li><a href="index.html">Home</a></li>

EOD;

if (isset($_SESSION['user_id'])) {

if (user_in_group($_COOKIE['user_id'], "Administrators")) {

echo "<li><a href='show_users.php'>Manage Users</a></li>";

}

echo "<li><a href='show_user.php'>My Profile</a></li>";

echo "<li><a href='signout.php'>Sign Out</a></li>";

} else {

echo "<li><a href='signin.php'>Sign In</a></li>";

}

echo <<<EOD

</ul>

</div>

EOD;

display_messages($success_message, $error_message);

echo "</div> <!-- page_start -->";

}

Be sure and check your menu; when you’re logged in, you should see “Sign Out” and “My Profile.” When you’re signed out, you shouldn’t.

And Then Sign Out…

That leads you back to signing out. With cookies, you set the expiration value to a time in the past. With $_SESSION, you need to call unset on the session variable.

And, as odd as it may seem, you can’t work with $_SESSION—even if that work is to unset values—without calling session_start. Here’s what signout.php should look like:

<?php

session_start();

unset($_SESSION['user_id']);

unset($_SESSION['username']);

header('Location: signin.php');

exit();

?>

The cookies are gone, and once signout.php runs, so will your user’s sessions variables.

And just like that—less than 20 lines of code changed—you’ve moved out of cookies and into sessions. Nice work. Your security-conscious users will thank you for it.

Memory Lane: Remember that Phishing Problem?

There’s just one little annoyance left to which you should attend. Remember the phishing problem way back on Welcome to Security and Phishing? It had to do with your use of error_message as a request parameter to show_error.php. show_error.php takes in the error message it displays from a request parameter:

if (isset($_REQUEST['error_message'])) {

$error_message = preg_replace("/\\\\/", '', $_REQUEST['error_message']);

} else {

$error_message = "something went wrong, and that's how you ended up

here.";

}

NOTE

This code is in scripts/show_error.php (Testing out Your Faulty Solution).

And you saw that a URL like this…

http://yellowtagmedia.com/phpMM/ch07/show_error.php?error_message=%3Ca%20href=%22http://www.syfy.com/beinghuman%22%3EClick%20Here%20To%20Report%20Your%20Error%3C/a%3E

…could create a page that looks like Figure 13-10. Seemingly safe, but actually, not so much.

Remember this example of a phishing scam (page 205)? Click on the innocent looking link, and you end up on a totally different website. Add in some CSS to match your site and a form to take in information, and your users are going to get scammed.

Figure 13-10. Remember this example of a phishing scam (page 205)? Click on the innocent looking link, and you end up on a totally different website. Add in some CSS to match your site and a form to take in information, and your users are going to get scammed.

But now with sessions, you don’t have to settle for this security hole. The problem is that you’ve been letting a request parameter handle the error message payload. But now, with sessions, you can remove those errors from view. This means that a hacker can’t possibly force-feed in a bad request parameter because you’re no longer using those parameters for that purpose.

Hop back over to scripts/app_config.php, and look at handle_error:

function handle_error($user_error_message, $system_error_message) {

header("Location: " . get_web_path(SITE_ROOT) .

"scripts/show_error.php?" .

"error_message={$user_error_message}&" .

"system_error_message={$system_error_message}");

header("Location: " . get_web_path(SITE_ROOT) . "scripts/show_error.php");

exit();

}

That’s the code that turns a PHP-supplied error into a request parameter. But now you can rework this using sessions:

function handle_error($user_error_message, $system_error_message) {

session_start();

$_SESSION['error_message'] = $user_error_message;

$_SESSION['system_error_message'] = $system_error_message;

header("Location: " . get_web_path(SITE_ROOT) . "scripts/show_error.php");

exit();

}

It’s a simple change. In fact, it makes handle_error a lot clearer.

Now, open up show_error.php and make the accompanying change to pull values from the session:

<?php

require 'app_config.php';

session_start();

if (isset($_SESSION['error_message'])) {

$error_message = preg_replace("/\\\\/", '', $_SESSION['error_message']);

} else {

$error_message = "something went wrong, and that's how you ended up

here.";

}

if (isset($_SESSION['system_error_message'])) {

$system_error_message = preg_replace("/\\\\/", '',

$_SESSION['system_error_message']);

} else {

$system_error_message = "No system-level error message was reported.";

}

?>

NOTE

The HTML portion below the PHP stays exactly the same.

Now, update the problematic URL to reflect the new location of show_user.php (in your scripts/ directory). So it might look something like this:

http://www.yellowtagmedia.com/phpMM/scripts/show_error.php?error_message=%3Ca%20href=%22http://www.syfy.com/beinghuman%22%3EClick%20Here%20To%20Report%20Your%20Error%3C/a%3E

NOTE

You should be able to replace the domain name, and update the path, but leave the file name and request parameters the same.

Now, visit that page in your browser. You should see a response like that shown in Figure 13-11.

Sessions protect you, and in many cases, simplify your code. A session is often a better choice for passing data between scripts, and it certainly beats using request parameters in most cases.

Figure 13-11. Sessions protect you, and in many cases, simplify your code. A session is often a better choice for passing data between scripts, and it certainly beats using request parameters in most cases.

Now, that phishing message is gone. Because the error message is stored in the session, it’s resistant to someone coming along and controlling the message via the URL. It’s a tiny change with huge implications for your users.

So Why Ever Use Cookies?

It’s easy to think that sessions are the answer for everything. They’re not, though. Probably the biggest limitation with sessions is that when the browser closes, the session’s over. There’s no way to get around this limitation. So if you want to offer users the ability to remain logged in across browser closings, sessions simply aren’t an option. You’ve got to use cookies.

Second, just because cookies can be used poorly doesn’t mean that they have to be used poorly. You can expire your cookies more frequently. You can store only very small bits of information in your cookies. And you can avoid storing much meaningful data in cookies. In fact, you may choose to do a few extra database lookups—even causing your app to run a little slower—to avoid storing much useful information on your users’ machines.

Of course, like almost everything at this stage of the game, you’re going to have to make a good decision for your application. But that’s no problem. You know what you’re doing now, and you know the tools at your disposal. Use them wisely, play around, and learn…always learn.