Sessions in Play Framework

on waitingforcode.com

Sessions in Play Framework

In our's Play application, user can navigate on the site, add several products to his shopping cart and, at the end, terminate his shopping. As in all "classical" web applications, in Play this persistence is possible thanks to sessions.

The first part of the article will present sessions from technical point of view. We'll see how Play works with persistent data and which session scopes are available. At the second part we'll implement sessions in our application and allow an user to buy one or more products from the store. The last part will be a hacking part. We'll try to break down session mechanism in Play Framework.

Sessions handling in Play Framework

If you want to use the sessions in the same way as in Tomcat's HttpSession, you should forget it immediately. Play Framework can store only String instances. In additionally, session data can take only 4kb of data because data is stored in the cookie called PLAY_SESSION. This kind of constraint a little bit logical. If enhances stateless character of the application, so also its scalability. For more complex use of Play sessions (shopping carts), you should think about alternatives as NoSQL, file system or database. In our case we'll want to code simple shopping cart which id and access key will be stored in the session and the rest of data in the database.

The session data stored in a cookie is a vulnerable data. If user uses shared computer, someone else can attempt to modify session information. However, it won't be possible in Play Framework. Data stored in Play's session is signed with a secret key and user can't change the data without knowing the key. To understand how the framework does it, we need to enter to Scala ground and open Http.scala. As you can see they're a Session object extending CookieBaker which defines following decode method:

def decode(data: String): Map[String, String] = {

      def urldecode(data: String) = {
        data
          .split("&")
          .map(_.split("=", 2))
          .map(p => URLDecoder.decode(p(0), "UTF-8") -> URLDecoder.decode(p(1), "UTF-8"))
          .toMap
      }

      // Do not change this unless you understand the security issues behind timing attacks.
      // This method intentionally runs in constant time if the two strings have the same length.
      // If it didn't, it would be vulnerable to a timing attack.
      def safeEquals(a: String, b: String) = {
        if (a.length != b.length) {
          false
        } else {
          var equal = 0
          for (i <- Array.range(0, a.length)) {
            equal |= a(i) ^ b(i)
          }
          equal == 0
        }
      }

      try {
        if (isSigned) {
          val splitted = data.split("-", 2)
          val message = splitted.tail.mkString("-")
          if (safeEquals(splitted(0), Crypto.sign(message)))
            urldecode(message)
          else
            Map.empty[String, String]
        } else urldecode(data)
      } catch {
        // fail gracefully is the session cookie is corrupted
        case NonFatal(_) => Map.empty[String, String]
      }
    }

Thanks to last try-catch block, you can get an idea about storing secured data in session cookie. Session cookie in Play looks like "553a7dbc9850ef6db1c0b8320cc792e9c498315d-accessKey=AFDDFSDFD3&cartId=1" where the first value represents encrypted representation of values stored in the cookie. In this try-catch block, we start by splitting the cookie and getting this encrypted proof (553a7dbc9850ef6db1c0b8320cc792e9c498315d). After that it calls safeEquals local method which compares this proof with the trace generated by Crypto.sign() method. Data which is encrypted, is the result of splitted.tail.mkString("-") invocation which takes all splitted elements without the first one (0 index) and concatenate them with "-" character. So, it compares "553a7dbc9850ef6db1c0b8320cc792e9c498315d" with Crypto.sign("accessKey=AFDDFSDFD3&cartId=1"). If both are equal, it returns the data. Otherwise, an empty Map is returned. This comparison technique is called web parameter tampering.

Beware, Crypot.sign() use a shared security key. It means that the same key can be used in production and in development environment. Play advises to use a new security key for each environment. If you are still looking on Http.scala file, you'll see which options we can specify to manage sessions:
- session.cookieName: it can override the name of cookie containing session data. If not specified, the default value (PLAY_SESSION) will be used.
- session.secure: if true, cookie-based sessions are enabled for HTTPS connections (false by default).
- session.maxAge: defines the max age of session data. Depending of the Play version, the time must be specified with time units or in miliseconds.
- session.httpOnly: if true, enables HTTP only flag in the cookie. When this flag is activated, it won't be possible to access cookie data from client side, as JavaScript document.cookie. Its default value is true.
- session.domain: represents the domain to which cookie should apply.

If you're familiar with web use of Spring, you should know that they're some "flash" variables which can store given value only for the time of one request. For example, if you submit a form in one page, you can set error messages in the second request as flash values. Now, if you return to the form page, you will able to display this values. But if you refresh again the page (2nd request after the set of flash values), the messages won't be available. Play Framework supports exactly the same mechanism. Even more, the variables living only one request are also called "flash" variables. By default, flash variables aren't encrypted and are represented by PLAY_FLASH cookie. They also can store only String values.

One interesting thing is to note about flash variables. When you use them in "static" context, for example by returning normal views instead of redirect to another page, you'll see following warning message in the logs:

[warn] play - You are using status code '200' with flashing, which should only be used with a redirect status!

Implement sessions in Play Framework

As we mentioned at the begin of the article, we'll use a shopping cart case to show session use in Play Framework. We must begin with the creation of "evolution SQL script". An evolution script is a script which suggests to Play that database needs to be updated (new table created, new column added...). This scripts must be placed in /conf/evolutions/default folder, where default represents the name of database to which the script will be applied. If you are looking back to the article about databases in Play Framework, you'll see that we have only one database instance, called default. The evolution script must be composed by two parts: "#--- !Ups" which represents the update commands and "#--- !Downs" which contains the SQL queries which cancel the update commands. For example, in Ups we can specify "CREATE TABLE newTable" statement while in Downs "DROP TABLE newTable". This is our script which creates tables used to store data in shopping cart:

# --- !Ups
CREATE TABLE IF NOT EXISTS shopping_carts (
  id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
  created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  access_key VARCHAR(255) NOT NULL,
  PRIMARY KEY(id)
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS shopping_carts_products (
  shopping_carts_id INT(11) UNSIGNED NOT NULL,
  products_id INT(5) UNSIGNED NOT NULL,
  quantity INT(2) NOT NULL,
  PRIMARY KEY(shopping_carts_id, products_id)
) ENGINE=InnoDB;


# --- !Downs
DROP TABLE shopping_carts;
DROP TABLE shopping_carts_products;

Now, when you launch your Play application, the logs will also contain some "evolution" notice:

(Server started, use Ctrl+D to stop and go back to the console...)

[info] play - datasource [mysql://root:root@localhost/play_store] bound to JNDI as DefaultDS
[info] play - database [default] connected at jdbc:mysql://localhost/play_store?useUnicode=yes&characterEncoding=UTF-8&connectionCollation=utf8_general_ci
[error] application - 

! @6ioop78jo - Internal server error, for (GET) [/categories/1/1] ->

play.api.db.evolutions.InvalidDatabaseRevision: Database 'default' needs evolution![An SQL script need to be run on your database.]
	at play.api.db.evolutions.EvolutionsPlugin$$anonfun$onStart$1$$anonfun$apply$1.apply$mcV$sp(Evolutions.scala:510) ~[play-jdbc_2.11-2.3.1.jar:2.3.1]
	at play.api.db.evolutions.EvolutionsPlugin.withLock(Evolutions.scala:531) ~[play-jdbc_2.11-2.3.1.jar:2.3.1]
	at play.api.db.evolutions.EvolutionsPlugin$$anonfun$onStart$1.apply(Evolutions.scala:485) ~[play-jdbc_2.11-2.3.1.jar:2.3.1]
	at play.api.db.evolutions.EvolutionsPlugin$$anonfun$onStart$1.apply(Evolutions.scala:483) ~[play-jdbc_2.11-2.3.1.jar:2.3.1]
	at scala.collection.immutable.List.foreach(List.scala:383) ~[scala-library-2.11.1.jar:na]
[warn] play - Error stopping plugin
java.lang.NullPointerException: null
	at play.libs.ws.ning.NingWSPlugin.onStop(NingWSPlugin.java:31) ~[play-java-ws_2.11-2.3.1.jar:2.3.1]
	at play.api.Play$$anonfun$stop$1$$anonfun$apply$1$$anonfun$apply$mcV$sp$2.apply(Play.scala:108) ~[play_2.11-2.3.1.jar:2.3.1]
	at play.api.Play$$anonfun$stop$1$$anonfun$apply$1$$anonfun$apply$mcV$sp$2.apply(Play.scala:107) ~[play_2.11-2.3.1.jar:2.3.1]
	at scala.collection.immutable.List.foreach(List.scala:383) ~[scala-library-2.11.1.jar:na]
	at play.api.Play$$anonfun$stop$1$$anonfun$apply$1.apply$mcV$sp(Play.scala:107) ~[play_2.11-2.3.1.jar:2.3.1]
[info] play - datasource [mysql://root:root@localhost/play_store] bound to JNDI as DefaultDS
[info] play - database [default] connected at jdbc:mysql://localhost/play_store?useUnicode=yes&characterEncoding=UTF-8&connectionCollation=utf8_general_ci
[info] play - Application started (Dev)

The evolution script can be applied manually, on the page from browser level. Once executed, we can check if two tables were correctly created in the database:

mysql> show tables;
+-------------------------+
| Tables_in_play_store    |
+-------------------------+
| categories              |
| play_evolutions         |
| products                |
| shopping_carts          |
| shopping_carts_products |
+-------------------------+
5 rows in set (0.00 sec)

They're here. But you see also a play_evolutions table. This table were created to handle evolution scripts. It looks like:

mysql> desc play_evolutions;
+---------------+--------------+------+-----+-------------------+-----------------------------+
| Field         | Type         | Null | Key | Default           | Extra                       |
+---------------+--------------+------+-----+-------------------+-----------------------------+
| id            | int(11)      | NO   | PRI | NULL              |                             |
| hash          | varchar(255) | NO   |     | NULL              |                             |
| applied_at    | timestamp    | NO   |     | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP |
| apply_script  | text         | YES  |     | NULL              |                             |
| revert_script | text         | YES  |     | NULL              |                             |
| state         | varchar(255) | YES  |     | NULL              |                             |
| last_problem  | text         | YES  |     | NULL              |                             |
+---------------+--------------+------+-----+-------------------+-----------------------------+
7 rows in set (0.03 sec)

mysql> select * from play_evolutions;
+----+------------------------------------------+---------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------------------------------------+---------+--------------+
| id | hash                                     | applied_at          | apply_script                                                                                                                                                                                                                                                                                                                                                                                                                                      | revert_script                                                  | state   | last_problem |
+----+------------------------------------------+---------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------------------------------------+---------+--------------+
|  1 | f3d5330f8c0a85e941310f3e5e7e8ea028c46b8e | 2014-07-13 12:02:09 | CREATE TABLE IF NOT EXISTS shopping_carts (
id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
access_key VARCHAR(255) NOT NULL,
PRIMARY KEY(id)
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS shopping_carts_products (
shopping_carts_id INT(11) UNSIGNED NOT NULL,
products_id INT(5) UNSIGNED NOT NULL,
quantity INT(2) NOT NULL,
PRIMARY KEY(shopping_carts_id, products_id)
) ENGINE=InnoDB; | DROP TABLE shopping_carts;
DROP TABLE shopping_carts_products; | applied |              |
+----+------------------------------------------+---------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------------------------------------+---------+--------------+
1 row in set (0.00 sec)

OK, we made some bases for our shopping cart. Let's now write a simple POJO and service to represent it in application side:

show shopping cart source code

We'll start by see how to get two session types: normal and flash. Both can be accessed in Controller through session() and flash() methods. These two methods are defined in play.mvc.Http.Context class. Http class contains also two static classes extending HashMap: Session and Flash. As you can imagine, the first is returned when the user calls session() and the second for flash() call. Both behave as containers because they only store values. To set one sesion value, we can use simple put(String key, String value) method. To retreive one information a simple get(String key) invocation need to be made. A session can be cleared with clear() method. You can find examples of these methods use in previously presented ShoppingCartService:

// return cart id from the session
String cartId = session.get(ShoppingCart.SESSION_ID_KEY);

// save new values to the session (only if these values doesn't exist - check made with containsKey(String key) method)
if (saved && !session.containsKey(ShoppingCart.SESSION_ID_KEY)) {
	session.put(ShoppingCart.SESSION_ID_KEY, String.valueOf(cart.getId()));
	session.put(ShoppingCart.SESSION_ACCESS_KEY, cart.getAccessKey());
}

Breaking down session in Play Framework

Until now we've seen basic manipulation of session in Play Framework. It was pretty simple, wasn't it ? Now we'll try to push the framework to its limits and try to break down it. First, we'll check the behavior when we modify the cookie manually. First, we override index method of our Application controller (responds for / URL) :

public static Result index() {
	Map queryParams = request().queryString();
	if (queryParams.containsKey("read")) {
		String testValue = session().get("testValue");
		Logger.debug("Test value after reading is:"+testValue);
	} else if (queryParams.containsKey("write")) {
		session().put("testValue", "OK");
		String testValue = session().get("testValue");
		Logger.debug("Test value after putting is:"+testValue);
	}
return ok(index.render("Your new application is ready."));
}

As you can see, depending on parameter in query string, we make reading or writing with session's testValue data. First, let's make a write by calling http://localhost:9000?write. We should retrieve this entry in the logs:

[debug] application - Test value after putting is: OK

Now we will try to read this session data with http://localhost:9000?read. We can see:

[debug] application - Test value after reading is: OK

Everything work well. So, it's the time to make some mess. We'll edit our PLAY_SESSION cookie and set NOK as value for testValue data. We'll make it with EditThisCookie. After editing and relaunching http://localhost:9000?read, we'll see following entry in the logs:

[debug] application - Test value after reading is: null

In additionally, when you look on available cookies, you'll see that only JSESSIONID cookie remains in the browser. PLAY_SESSION cookie was removed entirely because of corrupted data.

The behavior with corrupted data in session cookie is logical and more than useful four us, developers - we don't need to check manually if session data is correct and handle unexpected information. What does happen if we put to much data into session (more than 4kb) ? To see that, we'll modify our index method to:

@Transactional
public static Result index() {
	Map queryParams = request().queryString();
	if (queryParams.containsKey("read")) {
		String testValue = session().get("testValue");
		Logger.debug("Test value after reading is: "+testValue);
	} else if (queryParams.containsKey("write")) {
		StringBuilder value = new StringBuilder();
		// value text should have 6kb, so more than Play limit (4kb)
		for (int i = 0; i < 6000; i++) {
			value.append("a");
		}
		session().put("testValue", value.toString());
		String testValue = session().get("testValue");
		Logger.debug("Test value after putting is: "+testValue);
	}
	return ok(index.render("Your new application is ready."));
}

We make the same steps as previously. We start by writing the value with http://localhost:9000?write. We should see following entry in the logs (XXX is represented by 6000 of concatenated a):

[debug] application - Test value after putting is: XXX

Now, when we try to read with http://localhost:9000?read, this value won't be available (no PLAY_SESSION cookie set):

[debug] application - Test value after reading is: null

This limit doesn't come from Play but from the browser. If you want to store more data in the cookie, you can imagine to decompose data to multiple cookies. However, the browsers also have the limits for the number of cookies belonging to one domain.

Sessions in Play are a little bit different as in another frameworks. We can only store String information. Because this data is stored in cookies, its size is limited to 4kb. For more complex use, a support of NoSQL, databases or filesystem can be more than useful.

Share on: