diff --git a/play-scala-secure-session-example/.gitignore b/play-scala-secure-session-example/.gitignore new file mode 100644 index 000000000..193873de8 --- /dev/null +++ b/play-scala-secure-session-example/.gitignore @@ -0,0 +1,10 @@ +build +logs +target +/.idea +/.idea_modules +/.classpath +/.gradle +/.project +/.settings +/RUNNING_PID diff --git a/play-scala-secure-session-example/.mergify.yml b/play-scala-secure-session-example/.mergify.yml new file mode 100644 index 000000000..32f8689ae --- /dev/null +++ b/play-scala-secure-session-example/.mergify.yml @@ -0,0 +1,27 @@ +pull_request_rules: + - name: automatic merge on CI success require review + conditions: + - status-success=Travis CI - Pull Request + - "#approved-reviews-by>=1" + - "#changes-requested-reviews-by=0" + - label!=block-merge + actions: + merge: + method: squash + strict: smart + + - name: automatic merge on CI success for TemplateControl + conditions: + - status-success=Travis CI - Pull Request + - label=merge-when-green + - label!=block-merge + actions: + merge: + method: squash + strict: smart + + - name: delete branch after merge + conditions: + - merged + actions: + delete_head_branch: {} diff --git a/play-scala-secure-session-example/.travis.yml b/play-scala-secure-session-example/.travis.yml new file mode 100644 index 000000000..556608613 --- /dev/null +++ b/play-scala-secure-session-example/.travis.yml @@ -0,0 +1,53 @@ +language: scala +scala: + - 2.12.8 + +before_install: + - sudo add-apt-repository -y ppa:ondrej/php + - sudo apt-get -qq update + - sudo apt-get install -y libsodium-dev + - curl -sL https://github.com/shyiko/jabba/raw/master/install.sh | bash && . ~/.jabba/jabba.sh + +env: + global: + - JABBA_HOME=$HOME/.jabba + matrix: + # There is no concise way to specify multi-dimensional build matrix: + # https://github.com/travis-ci/travis-ci/issues/1519 + - SCRIPT=scripts/test-sbt TRAVIS_JDK=adopt@1.8.192-12 + - SCRIPT=scripts/test-sbt TRAVIS_JDK=adopt@1.11.0-1 + - SCRIPT=scripts/test-gradle TRAVIS_JDK=adopt@1.8.192-12 + - SCRIPT=scripts/test-gradle TRAVIS_JDK=adopt@1.11.0-1 + +# Exclude some combinations from build matrix. See: +# https://docs.travis-ci.com/user/customizing-the-build/#Build-Matrix +matrix: + fast_finish: true + allow_failures: + # Current release of Gradle still does not supports Play 2.7.x releases + # As soon as there is a release of Gradle that fixes that, we can then + # remove this allowed failure. + - env: SCRIPT=scripts/test-gradle TRAVIS_JDK=adopt@1.8.192-12 + - env: SCRIPT=scripts/test-gradle TRAVIS_JDK=adopt@1.11.0-1 + # Java 11 is still not fully supported. It is good that we are already + # testing our sample applications to better discover possible problems + # but we can allow failures here too. + - env: SCRIPT=scripts/test-sbt TRAVIS_JDK=adopt@1.11.0-1 + +install: + - $JABBA_HOME/bin/jabba install $TRAVIS_JDK + - unset _JAVA_OPTIONS + - export JAVA_HOME="$JABBA_HOME/jdk/$TRAVIS_JDK" && export PATH="$JAVA_HOME/bin:$PATH" && java -Xmx32m -version + +script: + - $SCRIPT + +before_cache: + - find $HOME/.ivy2 -name "ivydata-*.properties" -delete + - find $HOME/.sbt -name "*.lock" -delete + +cache: + directories: + - "$HOME/.ivy2/cache" + - "$HOME/.gradle/caches" + - "$HOME/.jabba/jdk" diff --git a/play-scala-secure-session-example/LICENSE b/play-scala-secure-session-example/LICENSE new file mode 100644 index 000000000..670154e35 --- /dev/null +++ b/play-scala-secure-session-example/LICENSE @@ -0,0 +1,116 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + diff --git a/play-scala-secure-session-example/NOTICE b/play-scala-secure-session-example/NOTICE new file mode 100644 index 000000000..6d6c034d3 --- /dev/null +++ b/play-scala-secure-session-example/NOTICE @@ -0,0 +1,8 @@ +Written by Lightbend + +To the extent possible under law, the author(s) have dedicated all copyright and +related and neighboring rights to this software to the public domain worldwide. +This software is distributed without any warranty. + +You should have received a copy of the CC0 Public Domain Dedication along with +this software. If not, see . diff --git a/play-scala-secure-session-example/README.md b/play-scala-secure-session-example/README.md new file mode 100644 index 000000000..9287342dd --- /dev/null +++ b/play-scala-secure-session-example/README.md @@ -0,0 +1,85 @@ +# play-scala-secure-session-example + +[![Build Status](https://travis-ci.org/playframework/play-scala-secure-session-example.svg?branch=2.6.x)](https://travis-ci.org/playframework/play-scala-secure-session-example) + +This is an example application that shows how to do simple secure session management in Play, using the Scala API and session cookies. + +## Overview + +Play has a simple session cookie that is signed, but not encrypted. This example shows how to securely store information in a client side cookie without revealing it to the browser, by encrypting the data with libsodium, a high level encryption library. + +The only server side state is a mapping of session ids to secret keys. When the user logs out, the mapping is deleted, and the encrypted information cannot be retrieved using the client's session id. This prevents replay attacks after logout, even if the user saves off the cookies and replays them with exactly the same browser and IP address. + +## Prerequisites + +As with all Play projects, you must have JDK 1.8 and [sbt](http://www.scala-sbt.org/) installed. + +However, you must install libsodium before using this application, which is a non-Java binary install. + +If you are on MacOS, you can use Homebrew: + +```bash +brew install libsodium +``` + +If you are on Ubuntu >= 15.04 or Debian >= 8, you can install with apt-get: + +```bash +apt-get install libsodium-dev +``` + +On Fedora: + +```bash +dnf install libsodium-devel +``` + +On CentOS: + +```bash +yum install libsodium-devel +``` + +For Windows, you can download pre-built libraries using the [install page](https://download.libsodium.org/doc/installation/). + +## Running + +Run sbt from the command line: + +```bash +sbt run +``` + +Then go to to see the server. + +## Encryption + +Encryption is handled by `services.encryption.EncryptionService`. It uses secret key authenticated encryption with [Kalium](https://github.com/abstractj/kalium/), a thin Java wrapper around libsodium. Kalium's `SecretBox` is an object oriented mapping to libsodium's `crypto_secretbox_easy` and `crypto_secretbox_open_easy`, described [here](https://download.libsodium.org/doc/secret-key_cryptography/authenticated_encryption.html). The underlying stream cipher is XSalsa20, used with a Poly1305 MAC. + +A abstract [cookie baker](https://www.playframework.com/documentation/latest/api/scala/index.html#play.api.mvc.CookieBaker), `EncryptedCookieBaker` is used to serialize and deserialize encrypted text between a `Map[String, String]` and a case class representation. `EncryptedCookieBaker` also extends the `JWTCookieDataCodec` trait, which handles the encoding between `Map[String, String]` and the raw string data written out in the HTTP response in [JWT format](https://tools.ietf.org/html/rfc7519). + +A factory `UserInfoCookieBakerFactory` creates a `UserInfoCookieBaker` that uses the session specific secret key to map a `UserInfo` case class to and from a cookie. + +Then finally, a `UserInfoAction`, an action builder, handles the work of reading in a `UserInfo` from a cookie and attaches it to a `UserRequest`, a [wrapped request](https://www.playframework.com/documentation/latest/ScalaActionsComposition) so that the controllers can work with `UserInfo` without involving themselves with the underlying logic. + +## Replicated Caching + +In a production environment, there will be more than one Play instance. This means that the session id to secret key to secret key mapping must be available to all the play instances, and when the session is deleted, the secret key must be removed from all the instances immediately. + +This example uses `services.session.SessionService` to provide a `Future` based API around a session store. + +### Distributed Data Session Store + +The example internally uses [Akka Distributed Data](http://doc.akka.io/docs/akka/current/scala/distributed-data.html) to share the map throughout all the Play instances through [Akka Clustering](http://doc.akka.io/docs/akka/current/scala/cluster-usage.html). Per the Akka docs, this is a good solution for up to 100,000 concurrent sessions. + +The basic structure of the cache is taken from [Akka's ReplicatedCache example](https://github.com/akka/akka-samples/blob/master/akka-sample-distributed-data-scala/src/main/scala/sample/distributeddata/ReplicatedCache.scala), but here an expiration time is added to ensure that an idle session will be reaped after reaching TTL, even if there is no explicit logout. This does result in an individual actor per session, but the ActorCell only becomes active when there is a change in session state, so this is very low overhead. + +Since this is an example, rather than having to run several Play instances, a ClusterSystem that runs two Akka cluster nodes in the background is used, and are configured as the seed nodes for the cluster, so you can see the cluster messages in the logs. In production, each Play instance should be part of the cluster and they will take care of themselves. + +> Note that the map is not persisted in this example, so **if all the Play instances go down at once, then everyone is logged out.** +> +> Also note that this uses Artery, which uses UDP without transport layer encryption. **It is assumed transport level encryption is handled by the datacenter.** + +### Database Session Store + +If the example's CRDT implementation is not sufficient, you can use a regular database as a session store. Redis, Cassandra, or even an SQL database are all fine -- SQL databases are [extremely fast](https://thebuild.com/blog/2015/10/30/dont-assume-postgresql-is-slow/) at retrieving simple values. diff --git a/play-scala-secure-session-example/app/Module.scala b/play-scala-secure-session-example/app/Module.scala new file mode 100644 index 000000000..df4260ff4 --- /dev/null +++ b/play-scala-secure-session-example/app/Module.scala @@ -0,0 +1,10 @@ +import com.google.inject.AbstractModule +import play.api.libs.concurrent.AkkaGuiceSupport +import services.session.{ ClusterSystem, SessionCache } + +class Module extends AbstractModule with AkkaGuiceSupport { + def configure(): Unit = { + bind(classOf[ClusterSystem]).asEagerSingleton() + bindActor[SessionCache]("replicatedCache") + } +} diff --git a/play-scala-secure-session-example/app/controllers/HomeController.scala b/play-scala-secure-session-example/app/controllers/HomeController.scala new file mode 100644 index 000000000..371e6bf89 --- /dev/null +++ b/play-scala-secure-session-example/app/controllers/HomeController.scala @@ -0,0 +1,17 @@ +package controllers + +import javax.inject._ + +import play.api.mvc._ + +@Singleton +class HomeController @Inject() ( + userAction: UserInfoAction, + cc: ControllerComponents +) extends AbstractController(cc) { + + def index = userAction { implicit request: UserRequest[_] => + Ok(views.html.index(form)) + } + +} diff --git a/play-scala-secure-session-example/app/controllers/LoginController.scala b/play-scala-secure-session-example/app/controllers/LoginController.scala new file mode 100644 index 000000000..2ba2ff5fa --- /dev/null +++ b/play-scala-secure-session-example/app/controllers/LoginController.scala @@ -0,0 +1,38 @@ +package controllers + +import javax.inject.{ Inject, Singleton } + +import play.api.data.Form +import play.api.mvc._ + +import scala.concurrent.{ ExecutionContext, Future } + +@Singleton +class LoginController @Inject() ( + userAction: UserInfoAction, + sessionGenerator: SessionGenerator, + cc: ControllerComponents +)(implicit ec: ExecutionContext) + extends AbstractController(cc) { + + def login = userAction.async { implicit request: UserRequest[AnyContent] => + val successFunc = { userInfo: UserInfo => + sessionGenerator.createSession(userInfo).map { + case (sessionId, encryptedCookie) => + val session = request.session + (SESSION_ID -> sessionId) + Redirect(routes.HomeController.index()) + .withSession(session) + .withCookies(encryptedCookie) + } + } + + val errorFunc = { badForm: Form[UserInfo] => + Future.successful { + BadRequest(views.html.index(badForm)).flashing(FLASH_ERROR -> "Could not login!") + } + } + + form.bindFromRequest().fold(errorFunc, successFunc) + } + +} diff --git a/play-scala-secure-session-example/app/controllers/LogoutController.scala b/play-scala-secure-session-example/app/controllers/LogoutController.scala new file mode 100644 index 000000000..eff6c45cc --- /dev/null +++ b/play-scala-secure-session-example/app/controllers/LogoutController.scala @@ -0,0 +1,26 @@ +package controllers + +import javax.inject.{ Inject, Singleton } + +import play.api.mvc._ +import services.session.SessionService + +@Singleton +class LogoutController @Inject() ( + sessionService: SessionService, + cc: ControllerComponents +) extends AbstractController(cc) { + + def logout = Action { implicit request: Request[AnyContent] => + // When we delete the session id, removing the secret key is enough to render the + // user info cookie unusable. + request.session.get(SESSION_ID).foreach { sessionId => + sessionService.delete(sessionId) + } + + discardingSession { + Redirect(routes.HomeController.index()) + } + } + +} diff --git a/play-scala-secure-session-example/app/controllers/package.scala b/play-scala-secure-session-example/app/controllers/package.scala new file mode 100644 index 000000000..4e6824ae5 --- /dev/null +++ b/play-scala-secure-session-example/app/controllers/package.scala @@ -0,0 +1,136 @@ +import javax.inject.{ Inject, Singleton } + +import play.api.http.SecretConfiguration +import play.api.i18n.MessagesApi +import play.api.libs.json.{ Format, Json } +import play.api.mvc._ +import services.encryption.{ EncryptedCookieBaker, EncryptionService } +import services.session.SessionService + +import scala.concurrent.duration._ +import scala.concurrent.{ ExecutionContext, Future } + +/** + * Methods and objects common to all controllers + */ +package object controllers { + + import play.api.data.Form + import play.api.data.Forms._ + + val SESSION_ID = "sessionId" + + val FLASH_ERROR = "error" + + val USER_INFO_COOKIE_NAME = "userInfo" + + case class UserInfo(username: String) + + object UserInfo { + // Use a JSON format to automatically convert between case class and JsObject + implicit val format: Format[UserInfo] = Json.format[UserInfo] + } + + val form = Form( + mapping( + "username" -> text + )(UserInfo.apply)(UserInfo.unapply) + ) + + def discardingSession(result: Result): Result = { + result.withNewSession.discardingCookies(DiscardingCookie(USER_INFO_COOKIE_NAME)) + } + + /** + * An action that pulls everything together to show user info that is in an encrypted cookie, + * with only the secret key stored on the server. + */ + @Singleton + class UserInfoAction @Inject() ( + sessionService: SessionService, + factory: UserInfoCookieBakerFactory, + playBodyParsers: PlayBodyParsers, + messagesApi: MessagesApi + )(implicit val executionContext: ExecutionContext) + extends ActionBuilder[UserRequest, AnyContent] with Results { + + override def parser: BodyParser[AnyContent] = playBodyParsers.anyContent + + override def invokeBlock[A](request: Request[A], block: (UserRequest[A]) => Future[Result]): Future[Result] = { + // deal with the options first, then move to the futures + val maybeFutureResult: Option[Future[Result]] = for { + sessionId <- request.session.get(SESSION_ID) + userInfoCookie <- request.cookies.get(USER_INFO_COOKIE_NAME) + } yield { + // Future can be flatmapped here and squished with a partial function + sessionService.lookup(sessionId).flatMap { + case Some(secretKey) => + val cookieBaker = factory.createCookieBaker(secretKey) + val maybeUserInfo = cookieBaker.decodeFromCookie(Some(userInfoCookie)) + + block(new UserRequest[A](request, maybeUserInfo, messagesApi)) + case None => + // We've got a user with a client session id, but no server-side state. + // Let's redirect them back to the home page without any session cookie stuff. + Future.successful { + discardingSession { + Redirect(routes.HomeController.index()) + }.flashing(FLASH_ERROR -> "Your session has expired!") + } + } + } + + maybeFutureResult.getOrElse { + block(new UserRequest[A](request, None, messagesApi)) + } + } + } + + trait UserRequestHeader extends PreferredMessagesProvider with MessagesRequestHeader { + def userInfo: Option[UserInfo] + } + + class UserRequest[A]( + request: Request[A], + val userInfo: Option[UserInfo], + val messagesApi: MessagesApi + ) extends WrappedRequest[A](request) with UserRequestHeader + + /** + * Creates a cookie baker with the given secret key. + */ + @Singleton + class UserInfoCookieBakerFactory @Inject() ( + encryptionService: EncryptionService, + secretConfiguration: SecretConfiguration + ) { + + def createCookieBaker(secretKey: Array[Byte]): EncryptedCookieBaker[UserInfo] = { + new EncryptedCookieBaker[UserInfo](secretKey, encryptionService, secretConfiguration) { + // This can also be set to the session expiration, but lets keep it around for example + override val expirationDate: FiniteDuration = 365.days + override val COOKIE_NAME: String = USER_INFO_COOKIE_NAME + } + } + } + + @Singleton + class SessionGenerator @Inject() ( + sessionService: SessionService, + userInfoService: EncryptionService, + factory: UserInfoCookieBakerFactory + )(implicit ec: ExecutionContext) { + + def createSession(userInfo: UserInfo): Future[(String, Cookie)] = { + // create a user info cookie with this specific secret key + val secretKey = userInfoService.newSecretKey + val cookieBaker = factory.createCookieBaker(secretKey) + val userInfoCookie = cookieBaker.encodeAsCookie(Some(userInfo)) + + // Tie the secret key to a session id, and store the encrypted data in client side cookie + sessionService.create(secretKey).map(sessionId => (sessionId, userInfoCookie)) + } + + } + +} diff --git a/play-scala-secure-session-example/app/services/encryption/EncryptedCookieBaker.scala b/play-scala-secure-session-example/app/services/encryption/EncryptedCookieBaker.scala new file mode 100644 index 000000000..66c6d4009 --- /dev/null +++ b/play-scala-secure-session-example/app/services/encryption/EncryptedCookieBaker.scala @@ -0,0 +1,38 @@ +package services.encryption + +import play.api.http.{ JWTConfiguration, SecretConfiguration } +import play.api.libs.json.Format +import play.api.mvc._ + +import scala.concurrent.duration._ + +/** + * An encrypted cookie baker that serializes using the encryption service and JSON implicits. + */ +abstract class EncryptedCookieBaker[A: Format]( + secretKey: Array[Byte], + encryptionService: EncryptionService, + val secretConfiguration: SecretConfiguration +) extends CookieBaker[Option[A]] with JWTCookieDataCodec { + + def expirationDate: FiniteDuration + + def COOKIE_NAME: String + + override val isSigned = true + override val path: String = "/" + override val emptyCookie: Option[A] = None + + override lazy val maxAge: Option[Int] = Option(expirationDate).map(_.toSeconds.toInt) + + // Ensure that JWT expires at the same time as maxAge + override lazy val jwtConfiguration: JWTConfiguration = JWTConfiguration(expiresAfter = Some(expirationDate)) + + override protected def serialize(jsonClass: Option[A]): Map[String, String] = { + encryptionService.encrypt(secretKey, jsonClass) + } + + override protected def deserialize(stringMap: Map[String, String]): Option[A] = { + encryptionService.decrypt(secretKey, stringMap) + } +} diff --git a/play-scala-secure-session-example/app/services/encryption/EncryptionService.scala b/play-scala-secure-session-example/app/services/encryption/EncryptionService.scala new file mode 100644 index 000000000..56a95724a --- /dev/null +++ b/play-scala-secure-session-example/app/services/encryption/EncryptionService.scala @@ -0,0 +1,63 @@ +package services.encryption + +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import javax.inject.{ Inject, Singleton } + +import play.api.{ Configuration, Logger } +import play.api.libs.json.{ JsResult, Json, Reads, Writes } + +/** + * Implementation of encryption service, using Play JSON implicits conversion + */ +@Singleton +class EncryptionService @Inject() (configuration: Configuration) { + + private val random = new SecureRandom() + + private val logger = Logger(this.getClass) + + // utility method for when we're showing off secret key without saving confidential info... + def newSecretKey: Array[Byte] = { + // Key must be 32 bytes for secretbox + import org.abstractj.kalium.NaCl.Sodium.CRYPTO_SECRETBOX_XSALSA20POLY1305_KEYBYTES + val buf = new Array[Byte](CRYPTO_SECRETBOX_XSALSA20POLY1305_KEYBYTES) + random.nextBytes(buf) + buf + } + + def encrypt[A: Writes](secretKey: Array[Byte], userInfo: Option[A]): Map[String, String] = { + val nonce = Nonce.createNonce() + val json = Json.toJson(userInfo) + val stringData = Json.stringify(json) + logger.info(s"encrypt: userInfo = $userInfo, stringData = $stringData") + + val rawData = stringData.getBytes(StandardCharsets.UTF_8) + val cipherText = box(secretKey).encrypt(nonce.raw, rawData) + + val nonceHex = encoder.encode(nonce.raw) + val cipherHex = encoder.encode(cipherText) + Map("nonce" -> nonceHex, "c" -> cipherHex) + } + + def decrypt[A: Reads](secretKey: Array[Byte], data: Map[String, String]): Option[A] = { + val nonceHex = data("nonce") + val nonce = Nonce.nonceFromBytes(encoder.decode(nonceHex)) + val cipherTextHex = data("c") + val cipherText = encoder.decode(cipherTextHex) + val rawData = box(secretKey).decrypt(nonce.raw, cipherText) + val stringData = new String(rawData, StandardCharsets.UTF_8) + val json = Json.parse(stringData) + val result = Json.fromJson[A](json).asOpt + logger.info(s"decrypt: json = $json, result = $result") + result + + } + + private def encoder = org.abstractj.kalium.encoders.Encoder.HEX + + private def box(secretKey: Array[Byte]) = { + new org.abstractj.kalium.crypto.SecretBox(secretKey) + } + +} diff --git a/play-scala-secure-session-example/app/services/encryption/Nonce.scala b/play-scala-secure-session-example/app/services/encryption/Nonce.scala new file mode 100644 index 000000000..96c161287 --- /dev/null +++ b/play-scala-secure-session-example/app/services/encryption/Nonce.scala @@ -0,0 +1,36 @@ +package services.encryption + +import org.abstractj.kalium.crypto.Random + +/** + * Nonces are used to ensure that encryption is completely random. They should be generated once per encryption. + * + * You can store and display nonces -- they are not confidential -- but you must never reuse them, ever. + */ +class Nonce(val raw: Array[Byte]) extends AnyVal + +object Nonce { + + // No real advantage over java.secure.SecureRandom, or a call to /dev/urandom + private val random = new Random() + + /** + * Creates a random nonce value. + */ + def createNonce(): Nonce = { + import org.abstractj.kalium.NaCl.Sodium.CRYPTO_SECRETBOX_XSALSA20POLY1305_NONCEBYTES + new Nonce(random.randomBytes(CRYPTO_SECRETBOX_XSALSA20POLY1305_NONCEBYTES)) + } + + /** + * Reconstitute a nonce that has been stored with a ciphertext. + */ + def nonceFromBytes(data: Array[Byte]): Nonce = { + import org.abstractj.kalium.NaCl.Sodium.CRYPTO_SECRETBOX_XSALSA20POLY1305_NONCEBYTES + if (data == null || data.length != CRYPTO_SECRETBOX_XSALSA20POLY1305_NONCEBYTES) { + throw new IllegalArgumentException("This nonce has an invalid size: " + data.length) + } + new Nonce(data) + } + +} diff --git a/play-scala-secure-session-example/app/services/session/ClusterSystem.scala b/play-scala-secure-session-example/app/services/session/ClusterSystem.scala new file mode 100644 index 000000000..d9b4a3a2a --- /dev/null +++ b/play-scala-secure-session-example/app/services/session/ClusterSystem.scala @@ -0,0 +1,38 @@ +package services.session + +import javax.inject.Inject + +import akka.actor.ActorSystem +import com.typesafe.config.ConfigFactory +import play.api.Configuration +import play.api.inject.ApplicationLifecycle + +import scala.concurrent.Future + +/** + * Start up Akka cluster nodes on different ports in the same JVM for + * the distributing caching. + * + * Normally you'd run several play instances, and the port would be the + * same while you had several different ip addresses. + */ +class ClusterSystem @Inject() (configuration: Configuration, applicationLifecycle: ApplicationLifecycle) { + private val systems = startup(Seq("2551", "2552")) + + def startup(ports: Seq[String]): Seq[ActorSystem] = { + ports.map { port => + // Override the configuration of the port + val config = ConfigFactory.parseString( + s"""akka.remote.artery.canonical.port = $port""" + ).withFallback(configuration.underlying) + + // use the same name as Play's application actor system, because these are + // supposed to be "remote" play instances all sharing a distribute cache + ActorSystem(config.getString("play.akka.actor-system"), config) + } + } + + applicationLifecycle.addStopHook { () => + Future.successful(systems.foreach(_.terminate())) + } +} diff --git a/play-scala-secure-session-example/app/services/session/SessionCache.scala b/play-scala-secure-session-example/app/services/session/SessionCache.scala new file mode 100644 index 000000000..00ed2a63b --- /dev/null +++ b/play-scala-secure-session-example/app/services/session/SessionCache.scala @@ -0,0 +1,137 @@ +package services.session + +import javax.inject.Inject + +import akka.actor.{ Actor, ActorLogging, ActorRef, Cancellable, Props } +import akka.event.LoggingReceive + +import scala.concurrent.duration._ + +/** + * A replicated key-store map using akka distributed data. The advantage of + * replication over distributed cache is that all the sessions are local on + * every machine, so there's no remote lookup necessary. + * + * Note that this doesn't serialize using protobuf and also isn't being sent over SSL, + * so it's still not as secure as it could be. Please see http://doc.akka.io/docs/akka/current/scala/remoting-artery.html#remote-security + * for more details. + * + * http://doc.akka.io/docs/akka/current/scala/distributed-data.html + */ +class SessionCache extends Actor with ActorLogging { + // This is from one of the examples covered in the akka distributed data section: + // https://github.com/akka/akka-samples/blob/master/akka-sample-distributed-data-scala/src/main/scala/sample/distributeddata/ReplicatedCache.scala + import SessionCache._ + import SessionExpiration._ + import akka.cluster.Cluster + import akka.cluster.ddata.{ DistributedData, LWWMap, LWWMapKey } + import akka.cluster.ddata.Replicator._ + + private val expirationTime: FiniteDuration = { + val expirationString = context.system.settings.config.getString("session.expirationTime") + Duration(expirationString).asInstanceOf[FiniteDuration] + } + + private[this] val replicator = DistributedData(context.system).replicator + private[this] implicit val cluster = Cluster(context.system) + + def receive = { + + case PutInCache(key, value) => + refreshSessionExpiration(key) + replicator ! Update(dataKey(key), LWWMap(), WriteLocal)(_ + (key -> value)) + + case Evict(key) => + destroySessionExpiration(key) + replicator ! Update(dataKey(key), LWWMap(), WriteLocal)(_ - key) + + case GetFromCache(key) => + replicator ! Get(dataKey(key), ReadLocal, Some(Request(key, sender()))) + + case g @ GetSuccess(LWWMapKey(_), Some(Request(key, replyTo))) => + refreshSessionExpiration(key) + g.dataValue match { + case data: LWWMap[_, _] => data.asInstanceOf[LWWMap[String, Array[Byte]]].get(key) match { + case Some(value) => replyTo ! Cached(key, Some(value)) + case None => replyTo ! Cached(key, None) + } + } + + case NotFound(_, Some(Request(key, replyTo))) => + replyTo ! Cached(key, None) + + case _: UpdateResponse[_] => // ok + } + + private def dataKey(key: String): LWWMapKey[String, Array[Byte]] = LWWMapKey(key) + + private def refreshSessionExpiration(key: String) = { + context.child(key) match { + case Some(sessionInstance) => + log.info(s"Refreshing session $key") + sessionInstance ! RefreshSession + case None => + log.info(s"Creating new session $key") + context.actorOf(SessionExpiration.props(key, expirationTime), key) + } + } + + private def destroySessionExpiration(key: String) = { + log.info(s"Destroying session $key") + context.child(key).foreach(context.stop) + } + +} + +object SessionCache { + def props: Props = Props[SessionCache] + + final case class PutInCache(key: String, value: Array[Byte]) + + final case class GetFromCache(key: String) + + final case class Cached(key: String, value: Option[Array[Byte]]) + + final case class Evict(key: String) + + private final case class Request(key: String, replyTo: ActorRef) +} + +class SessionExpiration(key: String, expirationTime: FiniteDuration) extends Actor with ActorLogging { + import SessionExpiration._ + import services.session.SessionCache.Evict + + private var maybeCancel: Option[Cancellable] = None + + override def preStart(): Unit = { + schedule() + } + + override def postStop(): Unit = { + cancel() + } + + override def receive: Receive = LoggingReceive { + case RefreshSession => reschedule() + } + + private def cancel() = { + maybeCancel.foreach(_.cancel()) + } + + private def reschedule(): Unit = { + cancel() + schedule() + } + + private def schedule() = { + val system = context.system + maybeCancel = Some(system.scheduler.scheduleOnce(expirationTime, context.parent, Evict(key))(system.dispatcher)) + } +} + +object SessionExpiration { + def props(key: String, expirationTime: FiniteDuration) = Props(classOf[SessionExpiration], key, expirationTime) + + final case object RefreshSession +} diff --git a/play-scala-secure-session-example/app/services/session/SessionService.scala b/play-scala-secure-session-example/app/services/session/SessionService.scala new file mode 100644 index 000000000..7b9e87d13 --- /dev/null +++ b/play-scala-secure-session-example/app/services/session/SessionService.scala @@ -0,0 +1,48 @@ +package services.session + +import javax.inject.{ Inject, Named, Singleton } + +import akka.actor.ActorRef +import akka.pattern.ask +import services.session.SessionCache._ + +import scala.concurrent.duration._ +import scala.concurrent.{ ExecutionContext, Future } + +/** + * A session service that ties session id to secret key using akka CRDTs + */ +@Singleton +class SessionService @Inject() (@Named("replicatedCache") cacheActor: ActorRef)(implicit ec: ExecutionContext) { + + implicit def akkaTimeout = akka.util.Timeout(300.milliseconds) + + def create(secretKey: Array[Byte]): Future[String] = { + val sessionId = newSessionId() + cacheActor ! PutInCache(sessionId, secretKey) + Future.successful(sessionId) + } + + def lookup(sessionId: String): Future[Option[Array[Byte]]] = { + (cacheActor ? GetFromCache(sessionId)).map { + case Cached(key: Any, value: Option[_]) => + value.asInstanceOf[Option[Array[Byte]]] + } + } + + def put(sessionId: String, secretKey: Array[Byte]): Future[Unit] = { + cacheActor ! PutInCache(sessionId, secretKey) + Future.successful(()) + } + + def delete(sessionId: String): Future[Unit] = { + cacheActor ? Evict(sessionId) + Future.successful(()) + } + + private val sr = new java.security.SecureRandom() + + private def newSessionId(): String = { + new java.math.BigInteger(130, sr).toString(32) + } +} diff --git a/play-scala-secure-session-example/app/views/index.scala.html b/play-scala-secure-session-example/app/views/index.scala.html new file mode 100644 index 000000000..fa7770948 --- /dev/null +++ b/play-scala-secure-session-example/app/views/index.scala.html @@ -0,0 +1,31 @@ +@(form: Form[UserInfo])(implicit request: UserRequestHeader) + +@main("play-scala-secure-session-example") { + + @request.flash.data.map{ case (k, v) => +

+ @k: @v +

+ } + +

+ Username is @{request.userInfo.map(_.username).getOrElse("undefined")} +

+ + @if(request.userInfo.isEmpty) { + @helper.form(routes.LoginController.login) { + @helper.CSRF.formField + @helper.inputText(form("username")) + + } + } + + @if(request.userInfo.isDefined) { +
+ @helper.CSRF.formField + +
+ } + + +} diff --git a/play-scala-secure-session-example/app/views/main.scala.html b/play-scala-secure-session-example/app/views/main.scala.html new file mode 100644 index 000000000..9414f4be6 --- /dev/null +++ b/play-scala-secure-session-example/app/views/main.scala.html @@ -0,0 +1,23 @@ +@* + * This template is called from the `index` template. This template + * handles the rendering of the page header and body tags. It takes + * two arguments, a `String` for the title of the page and an `Html` + * object to insert into the body of the page. + *@ +@(title: String)(content: Html) + + + + + @* Here's where we render the page title `String`. *@ + @title + + + + + + @* And here's where we render the `Html` object containing + * the page content. *@ + @content + + diff --git a/play-scala-secure-session-example/build.gradle b/play-scala-secure-session-example/build.gradle new file mode 100644 index 000000000..2fbdfce11 --- /dev/null +++ b/play-scala-secure-session-example/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'play' + id 'idea' +} + +def playVersion = "2.6.21" +def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") + +model { + components { + play { + platform play: playVersion, scala: scalaVersion, java: '1.8' + injectedRoutesGenerator = true + + sources { + twirlTemplates { + defaultImports = TwirlImports.SCALA + } + } + } + } +} + +dependencies { + play "com.typesafe.play:play-guice_$scalaVersion:$playVersion" + play "com.typesafe.play:play-logback_$scalaVersion:$playVersion" + play "com.typesafe.play:filters-helpers_$scalaVersion:$playVersion" + + play "org.abstractj.kalium:kalium:0.6.0" + play "com.typesafe.akka:akka-distributed-data_$scalaVersion:2.5.8" + + playTest "com.typesafe.play:play-ahc-ws_$scalaVersion:$playVersion" + playTest "org.scalatestplus.play:scalatestplus-play_$scalaVersion:3.1.2" +} + +repositories { + jcenter() + maven { + name "lightbend-maven-releases" + url "https://repo.lightbend.com/lightbend/maven-release" + } + ivy { + name "lightbend-ivy-release" + url "https://repo.lightbend.com/lightbend/ivy-releases" + layout "ivy" + } +} diff --git a/play-scala-secure-session-example/build.sbt b/play-scala-secure-session-example/build.sbt new file mode 100644 index 000000000..84af4473b --- /dev/null +++ b/play-scala-secure-session-example/build.sbt @@ -0,0 +1,14 @@ +name := """play-scala-secure-session-example""" + +version := "1.0-SNAPSHOT" + +lazy val root = (project in file(".")).enablePlugins(PlayScala) + +scalaVersion := "2.12.8" + +libraryDependencies += guice +libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.6.0" +libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data" % "2.5.17" + +libraryDependencies += "com.typesafe.play" %% "play-ahc-ws" % "2.6.9" % Test +libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.1.2" % Test diff --git a/play-scala-secure-session-example/conf/application.conf b/play-scala-secure-session-example/conf/application.conf new file mode 100644 index 000000000..3ad317a1a --- /dev/null +++ b/play-scala-secure-session-example/conf/application.conf @@ -0,0 +1,52 @@ + +# The SessionCache expiration time if not touched +session.expirationTime = 5 minutes + +# Show off distributed cache using akka distributed data +# http://doc.akka.io/docs/akka/current/scala/distributed-data.html +akka { + loggers = ["akka.event.slf4j.Slf4jLogger"] + loglevel = "DEBUG" + logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" + + actor { + provider = "cluster" + + # Do enable protobuf serialization + # http://doc.akka.io/docs/akka/current/scala/remoting.html#Disabling_the_Java_Serializer + enable-additional-serialization-bindings = on + + # Don't allow insecure java deserialization + allow-java-serialization = off + + serialization-bindings { + // Don't allow users to manually invoke java serialization. + "java.io.Serializable" = none + } + } + + remote { + log-remote-lifecycle-events = off + + artery { + enabled = on + canonical.hostname = "127.0.0.1" + canonical.port = 0 + } + } + + # Seed nodes are started by ClusterService (you'd typically have several + # play instances in production with different ip addresses and the same ports, + # but we fake it here) + cluster { + metrics.enabled = off + jmx.enabled = off + + min-nr-of-members = 2 + seed-nodes = [ + "akka://"${play.akka.actor-system}"@127.0.0.1:2551", + "akka://"${play.akka.actor-system}"@127.0.0.1:2552" + ] + } +} + diff --git a/play-scala-secure-session-example/conf/logback.xml b/play-scala-secure-session-example/conf/logback.xml new file mode 100644 index 000000000..8d09b07aa --- /dev/null +++ b/play-scala-secure-session-example/conf/logback.xml @@ -0,0 +1,35 @@ + + + + + + + ${application.home:-.}/logs/application.log + + %date [%level] from %logger in %thread - %message%n%xException + + + + + + %coloredLevel %logger{15} - %message%n%xException{10} + + + + + + + + + + + + + + + + + + + + diff --git a/play-scala-secure-session-example/conf/routes b/play-scala-secure-session-example/conf/routes new file mode 100644 index 000000000..8ad784d85 --- /dev/null +++ b/play-scala-secure-session-example/conf/routes @@ -0,0 +1,9 @@ +GET / controllers.HomeController.index + +POST /login controllers.LoginController.login + +POST /logout controllers.LogoutController.logout + + +# Map static resources from the /public folder to the /assets URL path +GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) diff --git a/play-scala-secure-session-example/gradle/wrapper/gradle-wrapper.jar b/play-scala-secure-session-example/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..01b8bf6b1 Binary files /dev/null and b/play-scala-secure-session-example/gradle/wrapper/gradle-wrapper.jar differ diff --git a/play-scala-secure-session-example/gradle/wrapper/gradle-wrapper.properties b/play-scala-secure-session-example/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..89dba2d9d --- /dev/null +++ b/play-scala-secure-session-example/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-bin.zip +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/play-scala-secure-session-example/gradlew b/play-scala-secure-session-example/gradlew new file mode 100755 index 000000000..cccdd3d51 --- /dev/null +++ b/play-scala-secure-session-example/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/play-scala-secure-session-example/gradlew.bat b/play-scala-secure-session-example/gradlew.bat new file mode 100644 index 000000000..e95643d6a --- /dev/null +++ b/play-scala-secure-session-example/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/play-scala-secure-session-example/project/build.properties b/play-scala-secure-session-example/project/build.properties new file mode 100644 index 000000000..c0bab0494 --- /dev/null +++ b/play-scala-secure-session-example/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.2.8 diff --git a/play-scala-secure-session-example/project/plugins.sbt b/play-scala-secure-session-example/project/plugins.sbt new file mode 100644 index 000000000..372755e6f --- /dev/null +++ b/play-scala-secure-session-example/project/plugins.sbt @@ -0,0 +1,2 @@ +// The Play plugin +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.21") diff --git a/play-scala-secure-session-example/public/images/favicon.png b/play-scala-secure-session-example/public/images/favicon.png new file mode 100644 index 000000000..c7d92d2ae Binary files /dev/null and b/play-scala-secure-session-example/public/images/favicon.png differ diff --git a/play-scala-secure-session-example/public/javascripts/hello.js b/play-scala-secure-session-example/public/javascripts/hello.js new file mode 100644 index 000000000..02ee13c7c --- /dev/null +++ b/play-scala-secure-session-example/public/javascripts/hello.js @@ -0,0 +1,3 @@ +if (window.console) { + console.log("Welcome to your Play application's JavaScript!"); +} diff --git a/play-scala-secure-session-example/public/stylesheets/main.css b/play-scala-secure-session-example/public/stylesheets/main.css new file mode 100644 index 000000000..e69de29bb diff --git a/play-scala-secure-session-example/scripts/test-gradle b/play-scala-secure-session-example/scripts/test-gradle new file mode 100755 index 000000000..de9857a71 --- /dev/null +++ b/play-scala-secure-session-example/scripts/test-gradle @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -e +set -o pipefail + +echo "+------------------------------+" +echo "| Executing tests using Gradle |" +echo "+------------------------------+" + +./gradlew check -i --stacktrace diff --git a/play-scala-secure-session-example/scripts/test-sbt b/play-scala-secure-session-example/scripts/test-sbt new file mode 100755 index 000000000..3a83bab73 --- /dev/null +++ b/play-scala-secure-session-example/scripts/test-sbt @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -e +set -o pipefail + +echo "+----------------------------+" +echo "| Executing tests using sbt |" +echo "+----------------------------+" +sbt test diff --git a/play-scala-secure-session-example/test/services/encryption/EncryptionServiceSpec.scala b/play-scala-secure-session-example/test/services/encryption/EncryptionServiceSpec.scala new file mode 100644 index 000000000..ad6409add --- /dev/null +++ b/play-scala-secure-session-example/test/services/encryption/EncryptionServiceSpec.scala @@ -0,0 +1,28 @@ +package services.encryption + +import org.scalatestplus.play._ +import org.scalatestplus.play.guice.GuiceOneAppPerTest +import play.api.libs.json.{Format, Json} + +case class Foo(name: String, age: Int) + +object Foo { + implicit val format: Format[Foo] = Json.format[Foo] +} + +class EncryptionServiceSpec extends PlaySpec with GuiceOneAppPerTest { + + "encryption info service" should { + + "symmetrically encrypt data" in { + val service = app.injector.instanceOf(classOf[EncryptionService]) + val secretKey = service.newSecretKey + val option = Option(Foo(name = "steve", age = 12)) + val encryptedMap = service.encrypt[Foo](secretKey, option) + val decrypted = service.decrypt[Foo](secretKey, encryptedMap) + decrypted mustBe Some(Foo(name = "steve", age = 12)) + } + + } + +}