From 11ed9b3b01eb1fd9cbdceabf411df3b18b96bcdb Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sat, 19 Mar 2016 21:29:16 -0700 Subject: [PATCH 01/70] Initial commit --- .gitignore | 8 ++ LICENSE | 8 ++ README.md | 33 ++++++ app/Module.scala | 10 ++ app/controllers/HomeController.scala | 31 ++++++ .../CreditCardEncryptionService.scala | 105 ++++++++++++++++++ app/views/index.scala.html | 5 + app/views/main.scala.html | 23 ++++ build.sbt | 14 +++ conf/application.conf | 13 +++ conf/logback.xml | 36 ++++++ conf/routes | 9 ++ project/build.properties | 4 + project/plugins.sbt | 18 +++ public/images/favicon.png | Bin 0 -> 687 bytes public/javascripts/hello.js | 3 + public/stylesheets/main.css | 0 .../CreditCardEncryptionServiceSpec.scala | 21 ++++ 18 files changed, 341 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/Module.scala create mode 100644 app/controllers/HomeController.scala create mode 100644 app/services/creditcard/CreditCardEncryptionService.scala create mode 100644 app/views/index.scala.html create mode 100644 app/views/main.scala.html create mode 100644 build.sbt create mode 100644 conf/application.conf create mode 100644 conf/logback.xml create mode 100644 conf/routes create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 public/images/favicon.png create mode 100644 public/javascripts/hello.js create mode 100644 public/stylesheets/main.css create mode 100644 test/services/creditcard/CreditCardEncryptionServiceSpec.scala diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..eb372fc71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +logs +target +/.idea +/.idea_modules +/.classpath +/.project +/.settings +/RUNNING_PID diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..4baedcb95 --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +This software is licensed under the Apache 2 license, quoted below. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project except in compliance with +the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..efcb6b788 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ += Play Kalium + +This is an example application that shows how to use symmetric encryption with [Kalium](https://github.com/abstractj/kalium/). + +You must install libsodium before using this application. If you have homebrew, you can use `brew install libsodium`. + +The credit card encryption service is where you'll find the symmetric encryption code. + +To use the encryption service, add something like this to a controller: + +``` +@Singleton +class HomeController @Inject()(creditCardEncryptionService: CreditCardEncryptionService) extends Controller { + + // Save the createNonce and the ciphertext to a database column. Nonces are not confidential, + // so if you need to you, you can pass them in a query parameter for decryption. + // Note that nonces should never be reused (nonce stands for N="once") + val cipherPair: (Nonce, Array[Byte]) = { + val ccNumber = "4111 1111 1111 1111".getBytes(StandardCharsets.UTF_8) + creditCardEncryptionService.encrypt(ccNumber) + } + + def index = Action { + val decryptedBytes = creditCardEncryptionService.decrypt(cipherPair) + val ccNumber = new String(decryptedBytes, StandardCharsets.UTF_8) + + Ok(views.html.index(ccNumber)) + } + +} +``` + +That's it. diff --git a/app/Module.scala b/app/Module.scala new file mode 100644 index 000000000..0a8abc48b --- /dev/null +++ b/app/Module.scala @@ -0,0 +1,10 @@ +import com.google.inject.AbstractModule +import services.creditcard.CreditCardEncryptionService + +class Module extends AbstractModule { + + override def configure() = { + bind(classOf[CreditCardEncryptionService]).asEagerSingleton() + } + +} diff --git a/app/controllers/HomeController.scala b/app/controllers/HomeController.scala new file mode 100644 index 000000000..e9ea1320d --- /dev/null +++ b/app/controllers/HomeController.scala @@ -0,0 +1,31 @@ +package controllers + +import java.nio.charset.StandardCharsets +import javax.inject._ + +import play.api.mvc._ +import services.creditcard.CreditCardEncryptionService +import services.creditcard.CreditCardEncryptionService.Nonce + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's home page. + */ +@Singleton +class HomeController @Inject()(creditCardEncryptionService: CreditCardEncryptionService) extends Controller { + + // Save the createNonce and the ciphertext to a database column. Nonces are not confidential, + // so if you need to you, you can pass them in a query parameter for decryption. + val cipherPair: (Nonce, Array[Byte]) = { + val ccNumber = "4111 1111 1111 1111".getBytes(StandardCharsets.UTF_8) + creditCardEncryptionService.encrypt(ccNumber) + } + + def index = Action { + val decryptedBytes = creditCardEncryptionService.decrypt(cipherPair) + val ccNumber = new String(decryptedBytes, StandardCharsets.UTF_8) + + Ok(views.html.index(ccNumber)) + } + +} diff --git a/app/services/creditcard/CreditCardEncryptionService.scala b/app/services/creditcard/CreditCardEncryptionService.scala new file mode 100644 index 000000000..eb2a44ee6 --- /dev/null +++ b/app/services/creditcard/CreditCardEncryptionService.scala @@ -0,0 +1,105 @@ +package services.creditcard + +import java.security.SecureRandom +import javax.inject.{Inject, Singleton} + +import org.abstractj.kalium.crypto.{Random, Util} +import play.api.Configuration + +/** + * For every service you need, you should specify a specific crypto service with its own keys. + * + * That is, if you have a service which encrypts credit cards, and another service which encrypts S3 credentials, they + * should not reuse the key. If you use the same key for both, then an attacker can cross reference between + * the encrypted values and reconstruct the key. This rule applies even if you are sharing the same key for hashing + * and encryption. + * + * Keeping distinct keys per service is known as the "key separation principle". + */ +@Singleton +class CreditCardEncryptionService @Inject()(configuration: Configuration) { + import CreditCardEncryptionService._ + + type CipherText = Array[Byte] + + type CipherPair = (Nonce, CipherText) + + private val encoder = org.abstractj.kalium.encoders.Encoder.HEX + + private val logger = org.slf4j.LoggerFactory.getLogger(this.getClass) + + // utility method for when we're showing off secret key without saving confidential info... + private def newSecretKey: Array[Byte] = { + // Key must be 32 bytes for secretbox + import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_KEYBYTES + val buf = new Array[Byte](XSALSA20_POLY1305_SECRETBOX_KEYBYTES) + new SecureRandom().nextBytes(buf) + buf + } + + private val box = { + // Storing key information confidentially is hard -- use an HSM or an encrypted filesystem or secret service. + val secretHex: String = configuration.getString("creditcard.crypto.secret").getOrElse { + val randomSecret = encoder.encode(newSecretKey) + logger.info(s"No secret found, creating temporary secret ${randomSecret}") + randomSecret + } + val secret = encoder.decode(secretHex) + new org.abstractj.kalium.crypto.SecretBox(secret) + } + + def encrypt(message: String): CipherPair = { + val nonce = createNonce() + val cipherText = box.encrypt(nonce.raw, encoder.decode(message)) + (nonce, cipherText) + } + + def encrypt(message: Array[Byte]): CipherPair = { + val nonce = createNonce() + val cipherText = box.encrypt(nonce.raw, message) + (nonce, cipherText) + } + + def decrypt(nonce: Nonce, cypherText: CipherText): Array[Byte] = { + box.decrypt(nonce.raw, cypherText) + } + + def decrypt(pair: CipherPair): Array[Byte] = { + box.decrypt(pair._1.raw, pair._2) + } + +} + +object CreditCardEncryptionService { + // No real advantage over java.secure.SecureRandom, or a call to /dev/urandom + private val random = new Random() + + /** + * Nonce 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. + * + * If you have to repeat an unsuccessful operation, use a different createNonce. Never retry with the same createNonce. + */ + class Nonce private[CreditCardEncryptionService](val raw: Array[Byte]) + + /** + * Creates a random createNonce value. + */ + private def createNonce(): Nonce = { + import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES + new Nonce(random.randomBytes(XSALSA20_POLY1305_SECRETBOX_NONCEBYTES)) + } + + /** + * Reconstitute a createNonce that has been stored with an individual ciphertext. + */ + def fromBytes(data: Array[Byte]): Nonce = { + import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES + if (data == null || data.length != XSALSA20_POLY1305_SECRETBOX_NONCEBYTES) { + throw new IllegalArgumentException("This createNonce has an invalid size: " + data.length) + } + new Nonce(data) + } + +} diff --git a/app/views/index.scala.html b/app/views/index.scala.html new file mode 100644 index 000000000..faa0478fe --- /dev/null +++ b/app/views/index.scala.html @@ -0,0 +1,5 @@ +@(creditCard: String) + +@main("Play-Kalium") { + The decrypted credit card number is @creditCard +} diff --git a/app/views/main.scala.html b/app/views/main.scala.html new file mode 100644 index 000000000..9414f4be6 --- /dev/null +++ b/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/build.sbt b/build.sbt new file mode 100644 index 000000000..e2d6056a5 --- /dev/null +++ b/build.sbt @@ -0,0 +1,14 @@ +name := """play-kalium""" + +version := "1.0-SNAPSHOT" + +lazy val root = (project in file(".")).enablePlugins(PlayScala) + +scalaVersion := "2.11.7" + +libraryDependencies ++= Seq( + "org.abstractj.kalium" % "kalium" % "0.4.0", + "org.scalatestplus.play" %% "scalatestplus-play" % "1.5.0-RC1" % Test +) + +resolvers += "scalaz-bintray" at "http://dl.bintray.com/scalaz/releases" diff --git a/conf/application.conf b/conf/application.conf new file mode 100644 index 000000000..e2dccbec1 --- /dev/null +++ b/conf/application.conf @@ -0,0 +1,13 @@ + +## Secret key +# http://www.playframework.com/documentation/latest/ApplicationSecret +# ~~~~~ +# The secret key is used to sign Play's session cookie. +# This must be changed for production, but we don't recommend you change it in this file. +play.crypto.secret = "changeme" + +play.i18n { + # The application languages + langs = [ "en" ] +} + diff --git a/conf/logback.xml b/conf/logback.xml new file mode 100644 index 000000000..9ffb72033 --- /dev/null +++ b/conf/logback.xml @@ -0,0 +1,36 @@ + + + + + + + ${application.home:-.}/logs/application.log + + %date [%level] from %logger in %thread - %message%n%xException + + + + + + %coloredLevel %logger{15} - %message%n%xException{10} + + + + + + + + + + + + + + + + + + + + + diff --git a/conf/routes b/conf/routes new file mode 100644 index 000000000..18027a30a --- /dev/null +++ b/conf/routes @@ -0,0 +1,9 @@ +# Routes +# This file defines all application routes (Higher priority routes first) +# ~~~~ + +# An example controller showing a sample home page +GET / controllers.HomeController.index + +# 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/project/build.properties b/project/build.properties new file mode 100644 index 000000000..303f5397f --- /dev/null +++ b/project/build.properties @@ -0,0 +1,4 @@ +#Activator-generated Properties +#Sat Mar 19 19:05:01 PDT 2016 +template.uuid=1c7228d8-76c3-4463-9912-bafe53ed6b37 +sbt.version=0.13.11 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 000000000..96d14284c --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,18 @@ +// The Play plugin +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.0") + +// web plugins + +addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.0") + +addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.0") + +addSbtPlugin("com.typesafe.sbt" % "sbt-jshint" % "1.0.3") + +addSbtPlugin("com.typesafe.sbt" % "sbt-rjs" % "1.0.7") + +addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.0") + +addSbtPlugin("com.typesafe.sbt" % "sbt-mocha" % "1.1.0") + +addSbtPlugin("org.irundaia.sbt" % "sbt-sassify" % "1.4.2") diff --git a/public/images/favicon.png b/public/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c7d92d2ae47434d9a61c90bc205e099b673b9dd5 GIT binary patch literal 687 zcmV;g0#N;lP)ezT{T_ZJ?}AL z5NC{NW(ESID=>(O3&Eg8 zmA9J&6c`h4_f6L;=bU>_H8aNG`kfvCj9zomNt)?O;rzWqZs0LEt%1WB218%1fo9uB zsW^yhBR7C(mqN%GEK9&msg0~ zWY?#bf4q8G-~2KttQZ($odJvy&_-~f?9*ThK@fwR$U^1)p*8=_+^3BXx0$i1BC8XC zr21u6D5nVK&^!dOAw&|1E;qC3uFNj3*Jj#&%Oje@0D-nhfmM*o%^5f}-pxQ07(95H z3|LoV>V19w#rLgmRmtVy9!T3M3FUE3><0T8&b3yEsWcLW`0(=1+qsqc(k(ymBLK0h zK!6(6$7MX~M`-QA2$wk7n(7hhkJ}4Rwi-Vd(_ZFX1Yk7TXuB0IJYpo@kLb2G8m)E{ z`9v=!hi}fOytKckfN^C@6+Z*+MVI9-W_p@_3yyR#UYc0FTpD}i#k>c!wYCS)4v@E$ zchZCo=zV@)`v^$;V18ixdjFMY#q^2$wEX%{f(XD8POnsn$bpbClpC@hPxjzyO>pY|*pF3UU2tYcCN?rUk{Sskej70Mmu9vPwMYhO1m{AxAt(zqDT|0jP7FaX=6 V`?~}E4H^Id002ovPDHLkV1hC)G==~G literal 0 HcmV?d00001 diff --git a/public/javascripts/hello.js b/public/javascripts/hello.js new file mode 100644 index 000000000..02ee13c7c --- /dev/null +++ b/public/javascripts/hello.js @@ -0,0 +1,3 @@ +if (window.console) { + console.log("Welcome to your Play application's JavaScript!"); +} diff --git a/public/stylesheets/main.css b/public/stylesheets/main.css new file mode 100644 index 000000000..e69de29bb diff --git a/test/services/creditcard/CreditCardEncryptionServiceSpec.scala b/test/services/creditcard/CreditCardEncryptionServiceSpec.scala new file mode 100644 index 000000000..c8a1806cd --- /dev/null +++ b/test/services/creditcard/CreditCardEncryptionServiceSpec.scala @@ -0,0 +1,21 @@ +package services.creditcard + +import java.nio.charset.StandardCharsets + +import org.scalatestplus.play._ + +class CreditCardEncryptionServiceSpec extends PlaySpec with OneAppPerTest { + + "encryption" should { + + "encrypt" in { + val service = app.injector.instanceOf(classOf[CreditCardEncryptionService]) + val nonce = service.nonce() + val cipherText = service.encrypt(nonce, "derp".getBytes(StandardCharsets.UTF_8)) + val decrypted = service.decrypt(nonce, cipherText) + new String(decrypted, StandardCharsets.UTF_8) mustEqual("derp") + } + + } + +} From 4cbe8133e11c47f3cc7a629a651635b962defa9b Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sat, 19 Mar 2016 21:32:23 -0700 Subject: [PATCH 02/70] Fix up comments --- README.md | 2 +- app/controllers/HomeController.scala | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index efcb6b788..fd0d19b56 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ To use the encryption service, add something like this to a controller: @Singleton class HomeController @Inject()(creditCardEncryptionService: CreditCardEncryptionService) extends Controller { - // Save the createNonce and the ciphertext to a database column. Nonces are not confidential, + // Save the nonce and the ciphertext to a database column. Nonces are not confidential, // so if you need to you, you can pass them in a query parameter for decryption. // Note that nonces should never be reused (nonce stands for N="once") val cipherPair: (Nonce, Array[Byte]) = { diff --git a/app/controllers/HomeController.scala b/app/controllers/HomeController.scala index e9ea1320d..9b5365310 100644 --- a/app/controllers/HomeController.scala +++ b/app/controllers/HomeController.scala @@ -5,18 +5,14 @@ import javax.inject._ import play.api.mvc._ import services.creditcard.CreditCardEncryptionService -import services.creditcard.CreditCardEncryptionService.Nonce -/** - * This controller creates an `Action` to handle HTTP requests to the - * application's home page. - */ @Singleton class HomeController @Inject()(creditCardEncryptionService: CreditCardEncryptionService) extends Controller { - // Save the createNonce and the ciphertext to a database column. Nonces are not confidential, + // Save the nonce and the ciphertext to a database column. Nonces are not confidential, // so if you need to you, you can pass them in a query parameter for decryption. - val cipherPair: (Nonce, Array[Byte]) = { + // Note that nonces should never be reused (nonce stands for N="once") + val cipherPair: (CreditCardEncryptionService.Nonce, Array[Byte]) = { val ccNumber = "4111 1111 1111 1111".getBytes(StandardCharsets.UTF_8) creditCardEncryptionService.encrypt(ccNumber) } From dfe25b8a05a252d05a998b190a35cca91e812226 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sat, 19 Mar 2016 21:34:48 -0700 Subject: [PATCH 03/70] Fix up docs --- README.md | 12 ++++++++---- app/controllers/HomeController.scala | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fd0d19b56..418c11f37 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -= Play Kalium +# Play Kalium This is an example application that shows how to use symmetric encryption with [Kalium](https://github.com/abstractj/kalium/). @@ -8,12 +8,16 @@ The credit card encryption service is where you'll find the symmetric encryption To use the encryption service, add something like this to a controller: -``` +## Controller Usage Example + +```scala @Singleton class HomeController @Inject()(creditCardEncryptionService: CreditCardEncryptionService) extends Controller { - // Save the nonce and the ciphertext to a database column. Nonces are not confidential, - // so if you need to you, you can pass them in a query parameter for decryption. + // Save the nonce and the ciphertext to a database under normal circumstances. + // Nonces are not confidential, so if you need to, you can pass them in a + // query parameter for decryption. + // // Note that nonces should never be reused (nonce stands for N="once") val cipherPair: (Nonce, Array[Byte]) = { val ccNumber = "4111 1111 1111 1111".getBytes(StandardCharsets.UTF_8) diff --git a/app/controllers/HomeController.scala b/app/controllers/HomeController.scala index 9b5365310..c933533ca 100644 --- a/app/controllers/HomeController.scala +++ b/app/controllers/HomeController.scala @@ -9,8 +9,10 @@ import services.creditcard.CreditCardEncryptionService @Singleton class HomeController @Inject()(creditCardEncryptionService: CreditCardEncryptionService) extends Controller { - // Save the nonce and the ciphertext to a database column. Nonces are not confidential, - // so if you need to you, you can pass them in a query parameter for decryption. + // Save the nonce and the ciphertext to a database under normal circumstances. + // Nonces are not confidential, so if you need to, you can pass them in a + // query parameter for decryption. + // // Note that nonces should never be reused (nonce stands for N="once") val cipherPair: (CreditCardEncryptionService.Nonce, Array[Byte]) = { val ccNumber = "4111 1111 1111 1111".getBytes(StandardCharsets.UTF_8) From b09415f3a1a698b8a1174cc2cf81df1c323b8a6a Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sat, 19 Mar 2016 21:45:03 -0700 Subject: [PATCH 04/70] Fix scaladoc --- .../creditcard/CreditCardEncryptionService.scala | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/services/creditcard/CreditCardEncryptionService.scala b/app/services/creditcard/CreditCardEncryptionService.scala index eb2a44ee6..feff03342 100644 --- a/app/services/creditcard/CreditCardEncryptionService.scala +++ b/app/services/creditcard/CreditCardEncryptionService.scala @@ -75,16 +75,17 @@ object CreditCardEncryptionService { private val random = new Random() /** - * Nonce are used to ensure that encryption is completely random. They should be generated once per encryption. + * 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. * - * If you have to repeat an unsuccessful operation, use a different createNonce. Never retry with the same createNonce. + * We make it very easy to use nonces correctly here, because createNonce() is private, and encrypt() creates a + * nonce under the hood automatically and returns the nonce with the associated ciphertext. */ class Nonce private[CreditCardEncryptionService](val raw: Array[Byte]) /** - * Creates a random createNonce value. + * Creates a random nonce value. */ private def createNonce(): Nonce = { import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES @@ -92,7 +93,7 @@ object CreditCardEncryptionService { } /** - * Reconstitute a createNonce that has been stored with an individual ciphertext. + * Reconstitute a nonce that has been stored with a ciphertext. */ def fromBytes(data: Array[Byte]): Nonce = { import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES From a490f6c2b3c5e6c257c2d60bb955429981d6329c Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sun, 20 Mar 2016 16:47:16 -0700 Subject: [PATCH 05/70] Change to use UserInfoService --- README.md | 50 +++++--- app/Module.scala | 6 +- app/controllers/HomeController.scala | 49 +++++--- app/controllers/UserInfoCookieBaker.scala | 21 ++++ .../CreditCardEncryptionService.scala | 106 ----------------- app/services/user/UserInfoService.scala | 21 ++++ app/services/user/UserInfoServiceImpl.scala | 111 ++++++++++++++++++ app/views/index.scala.html | 4 +- conf/logback.xml | 2 +- .../CreditCardEncryptionServiceSpec.scala | 21 ---- test/services/user/UserInfoServiceSpec.scala | 18 +++ 11 files changed, 243 insertions(+), 166 deletions(-) create mode 100644 app/controllers/UserInfoCookieBaker.scala delete mode 100644 app/services/creditcard/CreditCardEncryptionService.scala create mode 100644 app/services/user/UserInfoService.scala create mode 100644 app/services/user/UserInfoServiceImpl.scala delete mode 100644 test/services/creditcard/CreditCardEncryptionServiceSpec.scala create mode 100644 test/services/user/UserInfoServiceSpec.scala diff --git a/README.md b/README.md index 418c11f37..1fdf2c7fa 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is an example application that shows how to use symmetric encryption with [ You must install libsodium before using this application. If you have homebrew, you can use `brew install libsodium`. -The credit card encryption service is where you'll find the symmetric encryption code. +The UserInfoServiceImpl class is where you'll find the symmetric encryption code. To use the encryption service, add something like this to a controller: @@ -12,25 +12,41 @@ To use the encryption service, add something like this to a controller: ```scala @Singleton -class HomeController @Inject()(creditCardEncryptionService: CreditCardEncryptionService) extends Controller { - - // Save the nonce and the ciphertext to a database under normal circumstances. - // Nonces are not confidential, so if you need to, you can pass them in a - // query parameter for decryption. - // - // Note that nonces should never be reused (nonce stands for N="once") - val cipherPair: (Nonce, Array[Byte]) = { - val ccNumber = "4111 1111 1111 1111".getBytes(StandardCharsets.UTF_8) - creditCardEncryptionService.encrypt(ccNumber) +class HomeController @Inject()(userInfoService: UserInfoService, cookieBaker: UserInfoCookieBaker) extends Controller { + + def index = Action { implicit request => + val optionCookie = request.cookies.get(cookieBaker.COOKIE_NAME) + optionCookie match { + case Some(_) => + // We can see that the user is a terrible person, and deserves no cake, + // but the user cannot see the information in the cookie. + try { + val userInfo = cookieBaker.decodeFromCookie(optionCookie) + if (userInfo.terriblePerson) { + Ok(views.html.index(s"I'm sorry. All the cake is gone.")) + } else { + Ok(views.html.index("Hi! We have cake!")) + } + } catch { + case ex: RuntimeException if (ex.getMessage == "Decryption failed. Ciphertext failed verification") => + // This happens if you're in dev mode without a persisted secret and you + // reload the app server, because a new secret is generated but you still have the + // old cookie. + val userInfoCookie = generateUserInfoCookie + Redirect(routes.HomeController.index()).withCookies(userInfoCookie) + } + case None => + val userInfoCookie = generateUserInfoCookie + Redirect(routes.HomeController.index()).withCookies(userInfoCookie) + } } - def index = Action { - val decryptedBytes = creditCardEncryptionService.decrypt(cipherPair) - val ccNumber = new String(decryptedBytes, StandardCharsets.UTF_8) - - Ok(views.html.index(ccNumber)) + private def generateUserInfoCookie: Cookie = { + // Encode information about the user that we'd rather they not know + val userInfo = UserInfo(terriblePerson = true) + val userInfoCookie = cookieBaker.encodeAsCookie(userInfo) + userInfoCookie } - } ``` diff --git a/app/Module.scala b/app/Module.scala index 0a8abc48b..33229409d 100644 --- a/app/Module.scala +++ b/app/Module.scala @@ -1,10 +1,12 @@ import com.google.inject.AbstractModule -import services.creditcard.CreditCardEncryptionService +import controllers.UserInfoCookieBaker +import services.user.{UserInfoService, UserInfoServiceImpl} class Module extends AbstractModule { override def configure() = { - bind(classOf[CreditCardEncryptionService]).asEagerSingleton() + bind(classOf[UserInfoService]).to(classOf[UserInfoServiceImpl]) + bind(classOf[UserInfoCookieBaker]).asEagerSingleton() } } diff --git a/app/controllers/HomeController.scala b/app/controllers/HomeController.scala index c933533ca..3e013e2da 100644 --- a/app/controllers/HomeController.scala +++ b/app/controllers/HomeController.scala @@ -1,29 +1,44 @@ package controllers -import java.nio.charset.StandardCharsets import javax.inject._ import play.api.mvc._ -import services.creditcard.CreditCardEncryptionService +import services.user.{UserInfo, UserInfoService} @Singleton -class HomeController @Inject()(creditCardEncryptionService: CreditCardEncryptionService) extends Controller { +class HomeController @Inject()(userInfoService: UserInfoService, cookieBaker: UserInfoCookieBaker) extends Controller { - // Save the nonce and the ciphertext to a database under normal circumstances. - // Nonces are not confidential, so if you need to, you can pass them in a - // query parameter for decryption. - // - // Note that nonces should never be reused (nonce stands for N="once") - val cipherPair: (CreditCardEncryptionService.Nonce, Array[Byte]) = { - val ccNumber = "4111 1111 1111 1111".getBytes(StandardCharsets.UTF_8) - creditCardEncryptionService.encrypt(ccNumber) + def index = Action { implicit request => + val optionCookie = request.cookies.get(cookieBaker.COOKIE_NAME) + optionCookie match { + case Some(_) => + // We can see that the user is a terrible person, and deserves no cake, + // but the user cannot see the information in the cookie. + try { + val userInfo = cookieBaker.decodeFromCookie(optionCookie) + if (userInfo.terriblePerson) { + Ok(views.html.index(s"I'm sorry. All the cake is gone.")) + } else { + Ok(views.html.index("Hi! We have cake!")) + } + } catch { + case ex: RuntimeException if (ex.getMessage == "Decryption failed. Ciphertext failed verification") => + // This happens if you're in dev mode without a persisted secret and you + // reload the app server, because a new secret is generated but you still have the + // old cookie. + val userInfoCookie = generateUserInfoCookie + Redirect(routes.HomeController.index()).withCookies(userInfoCookie) + } + case None => + val userInfoCookie = generateUserInfoCookie + Redirect(routes.HomeController.index()).withCookies(userInfoCookie) + } } - def index = Action { - val decryptedBytes = creditCardEncryptionService.decrypt(cipherPair) - val ccNumber = new String(decryptedBytes, StandardCharsets.UTF_8) - - Ok(views.html.index(ccNumber)) + private def generateUserInfoCookie: Cookie = { + // Encode information about the user that we'd rather they not know + val userInfo = UserInfo(terriblePerson = true) + val userInfoCookie = cookieBaker.encodeAsCookie(userInfo) + userInfoCookie } - } diff --git a/app/controllers/UserInfoCookieBaker.scala b/app/controllers/UserInfoCookieBaker.scala new file mode 100644 index 000000000..e2aa55f7f --- /dev/null +++ b/app/controllers/UserInfoCookieBaker.scala @@ -0,0 +1,21 @@ +package controllers + +import javax.inject.{Inject, Singleton} + +import play.api.mvc.CookieBaker +import services.user.{UserInfo, UserInfoService} + +@Singleton +class UserInfoCookieBaker @Inject()(service: UserInfoService) extends CookieBaker[UserInfo] { + override def COOKIE_NAME: String = "userInfo" + + override def isSigned = false + + override def cookieSigner = { throw new IllegalStateException() } + + override def emptyCookie: UserInfo = new UserInfo() + + override protected def serialize(userInfo: UserInfo): Map[String, String] = service.encrypt(userInfo) + + override protected def deserialize(data: Map[String, String]): UserInfo = service.decrypt(data) +} diff --git a/app/services/creditcard/CreditCardEncryptionService.scala b/app/services/creditcard/CreditCardEncryptionService.scala deleted file mode 100644 index feff03342..000000000 --- a/app/services/creditcard/CreditCardEncryptionService.scala +++ /dev/null @@ -1,106 +0,0 @@ -package services.creditcard - -import java.security.SecureRandom -import javax.inject.{Inject, Singleton} - -import org.abstractj.kalium.crypto.{Random, Util} -import play.api.Configuration - -/** - * For every service you need, you should specify a specific crypto service with its own keys. - * - * That is, if you have a service which encrypts credit cards, and another service which encrypts S3 credentials, they - * should not reuse the key. If you use the same key for both, then an attacker can cross reference between - * the encrypted values and reconstruct the key. This rule applies even if you are sharing the same key for hashing - * and encryption. - * - * Keeping distinct keys per service is known as the "key separation principle". - */ -@Singleton -class CreditCardEncryptionService @Inject()(configuration: Configuration) { - import CreditCardEncryptionService._ - - type CipherText = Array[Byte] - - type CipherPair = (Nonce, CipherText) - - private val encoder = org.abstractj.kalium.encoders.Encoder.HEX - - private val logger = org.slf4j.LoggerFactory.getLogger(this.getClass) - - // utility method for when we're showing off secret key without saving confidential info... - private def newSecretKey: Array[Byte] = { - // Key must be 32 bytes for secretbox - import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_KEYBYTES - val buf = new Array[Byte](XSALSA20_POLY1305_SECRETBOX_KEYBYTES) - new SecureRandom().nextBytes(buf) - buf - } - - private val box = { - // Storing key information confidentially is hard -- use an HSM or an encrypted filesystem or secret service. - val secretHex: String = configuration.getString("creditcard.crypto.secret").getOrElse { - val randomSecret = encoder.encode(newSecretKey) - logger.info(s"No secret found, creating temporary secret ${randomSecret}") - randomSecret - } - val secret = encoder.decode(secretHex) - new org.abstractj.kalium.crypto.SecretBox(secret) - } - - def encrypt(message: String): CipherPair = { - val nonce = createNonce() - val cipherText = box.encrypt(nonce.raw, encoder.decode(message)) - (nonce, cipherText) - } - - def encrypt(message: Array[Byte]): CipherPair = { - val nonce = createNonce() - val cipherText = box.encrypt(nonce.raw, message) - (nonce, cipherText) - } - - def decrypt(nonce: Nonce, cypherText: CipherText): Array[Byte] = { - box.decrypt(nonce.raw, cypherText) - } - - def decrypt(pair: CipherPair): Array[Byte] = { - box.decrypt(pair._1.raw, pair._2) - } - -} - -object CreditCardEncryptionService { - // No real advantage over java.secure.SecureRandom, or a call to /dev/urandom - private val random = new 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. - * - * We make it very easy to use nonces correctly here, because createNonce() is private, and encrypt() creates a - * nonce under the hood automatically and returns the nonce with the associated ciphertext. - */ - class Nonce private[CreditCardEncryptionService](val raw: Array[Byte]) - - /** - * Creates a random nonce value. - */ - private def createNonce(): Nonce = { - import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES - new Nonce(random.randomBytes(XSALSA20_POLY1305_SECRETBOX_NONCEBYTES)) - } - - /** - * Reconstitute a nonce that has been stored with a ciphertext. - */ - def fromBytes(data: Array[Byte]): Nonce = { - import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES - if (data == null || data.length != XSALSA20_POLY1305_SECRETBOX_NONCEBYTES) { - throw new IllegalArgumentException("This createNonce has an invalid size: " + data.length) - } - new Nonce(data) - } - -} diff --git a/app/services/user/UserInfoService.scala b/app/services/user/UserInfoService.scala new file mode 100644 index 000000000..ee8ae9af4 --- /dev/null +++ b/app/services/user/UserInfoService.scala @@ -0,0 +1,21 @@ +package services.user + +import play.api.libs.json.{Json, OFormat} + +/** + * Defines a user info service trait that encrypts and decrypts user infos. + */ +trait UserInfoService { + def decrypt(data: Map[String, String]): UserInfo + + def encrypt(userInfo: UserInfo): Map[String, String] +} + +case class UserInfo(terriblePerson: Boolean = false) + +object UserInfo { + + // Use a JSON format to automatically convert between case class and JsObject + implicit val format: OFormat[UserInfo] = Json.format[UserInfo] + +} diff --git a/app/services/user/UserInfoServiceImpl.scala b/app/services/user/UserInfoServiceImpl.scala new file mode 100644 index 000000000..3a15dccf8 --- /dev/null +++ b/app/services/user/UserInfoServiceImpl.scala @@ -0,0 +1,111 @@ +package services.user + +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import javax.inject.{Inject, Singleton} + +import org.abstractj.kalium.crypto.Random +import play.api.Configuration +import play.api.libs.json.{JsResult, Json} + + +/** + * Implementation of user info service. + */ +@Singleton +class UserInfoServiceImpl @Inject()(configuration: Configuration) extends UserInfoService { + + import UserInfoServiceImpl._ + + private val encoder = org.abstractj.kalium.encoders.Encoder.HEX + + private val logger = org.slf4j.LoggerFactory.getLogger(this.getClass) + + // utility method for when we're showing off secret key without saving confidential info... + private def newSecretKey: Array[Byte] = { + // Key must be 32 bytes for secretbox + import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_KEYBYTES + val buf = new Array[Byte](XSALSA20_POLY1305_SECRETBOX_KEYBYTES) + new SecureRandom().nextBytes(buf) + buf + } + + private val box = { + /* + * For every service you need, you should specify a specific crypto service with its own keys. Keeping distinct + * keys per service is known as the "key separation principle". + * + * More specifically, if you have a service which encrypts user information, and another service which encrypts + * S3 credentials, they should not reuse the key. If you use the same key for both, then an attacker can cross + * reference between the encrypted values and reconstruct the key. This rule applies even if you are sharing + * the same key for hashing and encryption. + * + * Storing key information confidentially and doing key rotation properly is a specialized area. Check out Daniel Somerfield's talk: Turtles All the Way Down: Storing Secrets in the Cloud and the Data Center for the details. + */ + val secretHex: String = configuration.getString("user.crypto.secret").getOrElse { + val randomSecret = encoder.encode(newSecretKey) + logger.info(s"No secret found, creating temporary secret ${randomSecret}") + randomSecret + } + val secret = encoder.decode(secretHex) + new org.abstractj.kalium.crypto.SecretBox(secret) + } + + override def encrypt(userInfo: UserInfo): Map[String, String] = { + val nonce = createNonce() + val json = Json.toJson(userInfo) + val stringData = Json.stringify(json) + val rawData = stringData.getBytes(StandardCharsets.UTF_8) + val cipherText = box.encrypt(nonce.raw, rawData) + + val nonceHex = encoder.encode(nonce.raw) + val cipherHex = encoder.encode(cipherText) + Map("nonce" -> nonceHex, "c" -> cipherHex) + } + + override def decrypt(data: Map[String, String]): UserInfo = { + val nonceHex = data("nonce") + val nonce = nonceFromBytes(encoder.decode(nonceHex)) + val cipherTextHex = data("c") + val cipherText = encoder.decode(cipherTextHex) + val rawData = box.decrypt(nonce.raw, cipherText) + val stringData = new String(rawData, StandardCharsets.UTF_8) + val json = Json.parse(stringData) + val result: JsResult[UserInfo] = Json.fromJson[UserInfo](json) // uses UserInfo.format JSON magic. + result.get + } + +} + +object UserInfoServiceImpl { + + // No real advantage over java.secure.SecureRandom, or a call to /dev/urandom + private val random = new 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 private[UserInfoServiceImpl](val raw: Array[Byte]) extends AnyVal + + /** + * Creates a random nonce value. + */ + private def createNonce(): Nonce = { + import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES + new Nonce(random.randomBytes(XSALSA20_POLY1305_SECRETBOX_NONCEBYTES)) + } + + /** + * Reconstitute a nonce that has been stored with a ciphertext. + */ + private def nonceFromBytes(data: Array[Byte]): Nonce = { + import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES + if (data == null || data.length != XSALSA20_POLY1305_SECRETBOX_NONCEBYTES) { + throw new IllegalArgumentException("This nonce has an invalid size: " + data.length) + } + new Nonce(data) + } + +} diff --git a/app/views/index.scala.html b/app/views/index.scala.html index faa0478fe..4a11b7bb2 100644 --- a/app/views/index.scala.html +++ b/app/views/index.scala.html @@ -1,5 +1,5 @@ -@(creditCard: String) +@(userMessage: String) @main("Play-Kalium") { - The decrypted credit card number is @creditCard + @userMessage (See HomeController.scala for why you got this message) } diff --git a/conf/logback.xml b/conf/logback.xml index 9ffb72033..449ffab37 100644 --- a/conf/logback.xml +++ b/conf/logback.xml @@ -26,7 +26,7 @@ - + diff --git a/test/services/creditcard/CreditCardEncryptionServiceSpec.scala b/test/services/creditcard/CreditCardEncryptionServiceSpec.scala deleted file mode 100644 index c8a1806cd..000000000 --- a/test/services/creditcard/CreditCardEncryptionServiceSpec.scala +++ /dev/null @@ -1,21 +0,0 @@ -package services.creditcard - -import java.nio.charset.StandardCharsets - -import org.scalatestplus.play._ - -class CreditCardEncryptionServiceSpec extends PlaySpec with OneAppPerTest { - - "encryption" should { - - "encrypt" in { - val service = app.injector.instanceOf(classOf[CreditCardEncryptionService]) - val nonce = service.nonce() - val cipherText = service.encrypt(nonce, "derp".getBytes(StandardCharsets.UTF_8)) - val decrypted = service.decrypt(nonce, cipherText) - new String(decrypted, StandardCharsets.UTF_8) mustEqual("derp") - } - - } - -} diff --git a/test/services/user/UserInfoServiceSpec.scala b/test/services/user/UserInfoServiceSpec.scala new file mode 100644 index 000000000..75e728a76 --- /dev/null +++ b/test/services/user/UserInfoServiceSpec.scala @@ -0,0 +1,18 @@ +package services.user + +import org.scalatestplus.play._ + +class UserInfoServiceSpec extends PlaySpec with OneAppPerTest { + + "user info service" should { + + "symmetrically encrypt data" in { + val service = app.injector.instanceOf(classOf[UserInfoServiceImpl]) + val encryptedMap = service.encrypt(UserInfo(terriblePerson = true)) + val decryptedUserInfo = service.decrypt(encryptedMap) + decryptedUserInfo.terriblePerson mustBe true + } + + } + +} From b98684a488164f2ac44ab226987a833fc6c98524 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sun, 20 Mar 2016 17:04:32 -0700 Subject: [PATCH 06/70] Fill out the readme --- README.md | 140 +++++++++++++++++++- app/services/user/UserInfoServiceImpl.scala | 26 ++-- 2 files changed, 151 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 1fdf2c7fa..52b326c3e 100644 --- a/README.md +++ b/README.md @@ -50,4 +50,142 @@ class HomeController @Inject()(userInfoService: UserInfoService, cookieBaker: Us } ``` -That's it. +The CookieBaker will handle encryption and decryption automatically: + +```scala +@Singleton +class UserInfoCookieBaker @Inject()(service: UserInfoService) extends CookieBaker[UserInfo] { + override def COOKIE_NAME: String = "userInfo" + + override def isSigned = false + + override def cookieSigner = { throw new IllegalStateException() } + + override def emptyCookie: UserInfo = new UserInfo() + + override protected def serialize(userInfo: UserInfo): Map[String, String] = service.encrypt(userInfo) + + override protected def deserialize(data: Map[String, String]): UserInfo = service.decrypt(data) +} +``` + +Then the `UserInfoService` will have the settings: + +```scala +trait UserInfoService { + def decrypt(data: Map[String, String]): UserInfo + + def encrypt(userInfo: UserInfo): Map[String, String] +} + +case class UserInfo(terriblePerson: Boolean = false) + +object UserInfo { + + // Use a JSON format to automatically convert between case class and JsObject + implicit val format: OFormat[UserInfo] = Json.format[UserInfo] + +} +``` + +and the actual encryption and decryption are done using SecretBox: + +```scala +@Singleton +class UserInfoServiceImpl @Inject()(configuration: Configuration) extends UserInfoService { + + private val encoder = org.abstractj.kalium.encoders.Encoder.HEX + + private val logger = org.slf4j.LoggerFactory.getLogger(this.getClass) + + // utility method for when we're showing off secret key without saving confidential info... + private def newSecretKey: Array[Byte] = { + // Key must be 32 bytes for secretbox + import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_KEYBYTES + val buf = new Array[Byte](XSALSA20_POLY1305_SECRETBOX_KEYBYTES) + new SecureRandom().nextBytes(buf) + buf + } + + private val box = { + /* + * For every service you need, you should specify a specific crypto service with its own keys. Keeping distinct + * keys per service is known as the "key separation principle". + * + * More specifically, if you have a service which encrypts user information, and another service which encrypts + * S3 credentials, they should not reuse the key. If you use the same key for both, then an attacker can cross + * reference between the encrypted values and reconstruct the key. This rule applies even if you are sharing + * the same key for hashing and encryption. + * + * Storing key information confidentially and doing key rotation properly is a specialized area. Check out Daniel Somerfield's talk: Turtles All the Way Down: Storing Secrets in the Cloud and the Data Center for the details. + */ + val secretHex: String = configuration.getString("user.crypto.secret").getOrElse { + val randomSecret = encoder.encode(newSecretKey) + logger.info(s"No secret found, creating temporary secret ${randomSecret}") + randomSecret + } + val secret = encoder.decode(secretHex) + new org.abstractj.kalium.crypto.SecretBox(secret) + } + + override def encrypt(userInfo: UserInfo): Map[String, String] = { + val nonce = Nonce.createNonce() + val json = Json.toJson(userInfo) + val stringData = Json.stringify(json) + val rawData = stringData.getBytes(StandardCharsets.UTF_8) + val cipherText = box.encrypt(nonce.raw, rawData) + + val nonceHex = encoder.encode(nonce.raw) + val cipherHex = encoder.encode(cipherText) + Map("nonce" -> nonceHex, "c" -> cipherHex) + } + + override def decrypt(data: Map[String, String]): UserInfo = { + val nonceHex = data("nonce") + val nonce = Nonce.nonceFromBytes(encoder.decode(nonceHex)) + val cipherTextHex = data("c") + val cipherText = encoder.decode(cipherTextHex) + val rawData = box.decrypt(nonce.raw, cipherText) + val stringData = new String(rawData, StandardCharsets.UTF_8) + val json = Json.parse(stringData) + val result: JsResult[UserInfo] = Json.fromJson[UserInfo](json) // uses UserInfo.format JSON magic. + result.get + } + +} + +/** + * 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. + */ +private[user] class Nonce private[UserInfoServiceImpl](val raw: Array[Byte]) extends AnyVal + +private[user] 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.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES + new Nonce(random.randomBytes(XSALSA20_POLY1305_SECRETBOX_NONCEBYTES)) + } + + /** + * Reconstitute a nonce that has been stored with a ciphertext. + */ + def nonceFromBytes(data: Array[Byte]): Nonce = { + import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES + if (data == null || data.length != XSALSA20_POLY1305_SECRETBOX_NONCEBYTES) { + throw new IllegalArgumentException("This nonce has an invalid size: " + data.length) + } + new Nonce(data) + } + +} +``` + +That's it! diff --git a/app/services/user/UserInfoServiceImpl.scala b/app/services/user/UserInfoServiceImpl.scala index 3a15dccf8..cb229b079 100644 --- a/app/services/user/UserInfoServiceImpl.scala +++ b/app/services/user/UserInfoServiceImpl.scala @@ -15,8 +15,6 @@ import play.api.libs.json.{JsResult, Json} @Singleton class UserInfoServiceImpl @Inject()(configuration: Configuration) extends UserInfoService { - import UserInfoServiceImpl._ - private val encoder = org.abstractj.kalium.encoders.Encoder.HEX private val logger = org.slf4j.LoggerFactory.getLogger(this.getClass) @@ -52,7 +50,7 @@ class UserInfoServiceImpl @Inject()(configuration: Configuration) extends UserIn } override def encrypt(userInfo: UserInfo): Map[String, String] = { - val nonce = createNonce() + val nonce = Nonce.createNonce() val json = Json.toJson(userInfo) val stringData = Json.stringify(json) val rawData = stringData.getBytes(StandardCharsets.UTF_8) @@ -65,7 +63,7 @@ class UserInfoServiceImpl @Inject()(configuration: Configuration) extends UserIn override def decrypt(data: Map[String, String]): UserInfo = { val nonceHex = data("nonce") - val nonce = nonceFromBytes(encoder.decode(nonceHex)) + val nonce = Nonce.nonceFromBytes(encoder.decode(nonceHex)) val cipherTextHex = data("c") val cipherText = encoder.decode(cipherTextHex) val rawData = box.decrypt(nonce.raw, cipherText) @@ -77,22 +75,22 @@ class UserInfoServiceImpl @Inject()(configuration: Configuration) extends UserIn } -object UserInfoServiceImpl { +/** + * 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. + */ +private[user] class Nonce(val raw: Array[Byte]) extends AnyVal + +private[user] object Nonce { // No real advantage over java.secure.SecureRandom, or a call to /dev/urandom private val random = new 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 private[UserInfoServiceImpl](val raw: Array[Byte]) extends AnyVal - /** * Creates a random nonce value. */ - private def createNonce(): Nonce = { + def createNonce(): Nonce = { import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES new Nonce(random.randomBytes(XSALSA20_POLY1305_SECRETBOX_NONCEBYTES)) } @@ -100,7 +98,7 @@ object UserInfoServiceImpl { /** * Reconstitute a nonce that has been stored with a ciphertext. */ - private def nonceFromBytes(data: Array[Byte]): Nonce = { + def nonceFromBytes(data: Array[Byte]): Nonce = { import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES if (data == null || data.length != XSALSA20_POLY1305_SECRETBOX_NONCEBYTES) { throw new IllegalArgumentException("This nonce has an invalid size: " + data.length) From 2111da8eee99c8ebdb3e61b27aaaa425e7ec0f10 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Thu, 13 Oct 2016 12:15:41 -0700 Subject: [PATCH 07/70] Refactor for templatecontrol --- build.sbt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/build.sbt b/build.sbt index e2d6056a5..809f839b0 100644 --- a/build.sbt +++ b/build.sbt @@ -6,9 +6,7 @@ lazy val root = (project in file(".")).enablePlugins(PlayScala) scalaVersion := "2.11.7" -libraryDependencies ++= Seq( - "org.abstractj.kalium" % "kalium" % "0.4.0", - "org.scalatestplus.play" %% "scalatestplus-play" % "1.5.0-RC1" % Test -) +libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" +libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "1.5.1" % Test resolvers += "scalaz-bintray" at "http://dl.bintray.com/scalaz/releases" From af6f77779ca214599f6634f893a9e46652e64410 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Thu, 13 Oct 2016 12:29:44 -0700 Subject: [PATCH 08/70] Updated with template-control on 2016-10-13T19:17:27.500Z (#4) File-Pattern: **/build.sbt If-Found-In-Line: "com.typesafe.play" %% "play-slick" Replace-Line-With: libraryDependencies += "com.typesafe.play" %% "play-slick" % "2.0.2" If-Found-In-Line: "com.typesafe.play" %% "play-slick-evolutions" Replace-Line-With: libraryDependencies += "com.typesafe.play" %% "play-slick-evolutions" % "2.0.2" If-Found-In-Line: scala-guice Replace-Line-With: libraryDependencies += "net.codingwell" %% "scala-guice" % "4.1.0" If-Found-In-Line: "com.softwaremill.macwire" %% "macros" Replace-Line-With: libraryDependencies += "com.softwaremill.macwire" %% "macros" % "2.2.2" % "provided" If-Found-In-Line: gatling-charts-highcharts Replace-Line-With: libraryDependencies += "io.gatling.highcharts" % "gatling-charts-highcharts" % "2.2.2" % Test If-Found-In-Line: scalaVersion Replace-Line-With: scalaVersion := "2.11.8" If-Found-In-Line: scalatestplus-play Replace-Line-With: libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "1.5.1" % Test If-Found-In-Line: play-bootstrap Replace-Line-With: libraryDependencies += "com.adrianhurt" %% "play-bootstrap" % "1.0-P25-B3" If-Found-In-Line: gatling-test-framework Replace-Line-With: libraryDependencies += "io.gatling" % "gatling-test-framework" % "2.2.2" % Test If-Found-In-Line: "com.typesafe.play" %% "anorm" Replace-Line-With: libraryDependencies += "com.typesafe.play" %% "anorm" % "2.5.0" If-Found-In-Line: com.h2database Replace-Line-With: libraryDependencies += "com.h2database" % "h2" % "1.4.190" If-Found-In-Line: "com.softwaremill.macwire" %% "proxy" Replace-Line-With: libraryDependencies += "com.softwaremill.macwire" %% "proxy" % "2.2.2" If-Found-In-Line: "com.softwaremill.macwire" %% "util" Replace-Line-With: libraryDependencies += "com.softwaremill.macwire" %% "util" % "2.2.2" File-Pattern: **/build.properties If-Found-In-Line: sbt.version Replace-Line-With: sbt.version=0.13.12 File-Pattern: **/application.conf File-Pattern: **/plugins.sbt If-Found-In-Line: sbt-mocha Replace-Line-With: addSbtPlugin("com.typesafe.sbt" % "sbt-mocha" % "1.1.0") If-Found-In-Line: sbt-sassify Replace-Line-With: addSbtPlugin("org.irundaia.sbt" % "sbt-sassify" % "1.4.6") If-Found-In-Line: sbt-less Replace-Line-With: addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.0") If-Found-In-Line: sbt-play-enhancer Replace-Line-With: addSbtPlugin("com.typesafe.sbt" % "sbt-play-enhancer" % "1.1.0") If-Found-In-Line: sbt-play-ebean Replace-Line-With: addSbtPlugin("com.typesafe.sbt" % "sbt-play-ebean" % "3.0.1") If-Found-In-Line: sbt-jshint Replace-Line-With: addSbtPlugin("com.typesafe.sbt" % "sbt-jshint" % "1.0.4") If-Found-In-Line: sbt-coffeescript Replace-Line-With: addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.0") If-Found-In-Line: sbt-rjs Replace-Line-With: addSbtPlugin("com.typesafe.sbt" % "sbt-rjs" % "1.0.8") If-Found-In-Line: sbt-plugin Replace-Line-With: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.9") If-Found-In-Line: sbt-digest Replace-Line-With: addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.1") --- build.sbt | 2 +- project/build.properties | 2 +- project/plugins.sbt | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/build.sbt b/build.sbt index 809f839b0..41df02fc1 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,7 @@ version := "1.0-SNAPSHOT" lazy val root = (project in file(".")).enablePlugins(PlayScala) -scalaVersion := "2.11.7" +scalaVersion := "2.11.8" libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "1.5.1" % Test diff --git a/project/build.properties b/project/build.properties index 303f5397f..e5d830125 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1,4 +1,4 @@ #Activator-generated Properties #Sat Mar 19 19:05:01 PDT 2016 template.uuid=1c7228d8-76c3-4463-9912-bafe53ed6b37 -sbt.version=0.13.11 +sbt.version=0.13.12 diff --git a/project/plugins.sbt b/project/plugins.sbt index 96d14284c..f464ea705 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.0") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.9") // web plugins @@ -7,12 +7,12 @@ addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.0") addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.0") -addSbtPlugin("com.typesafe.sbt" % "sbt-jshint" % "1.0.3") +addSbtPlugin("com.typesafe.sbt" % "sbt-jshint" % "1.0.4") -addSbtPlugin("com.typesafe.sbt" % "sbt-rjs" % "1.0.7") +addSbtPlugin("com.typesafe.sbt" % "sbt-rjs" % "1.0.8") -addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.0") +addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.1") addSbtPlugin("com.typesafe.sbt" % "sbt-mocha" % "1.1.0") -addSbtPlugin("org.irundaia.sbt" % "sbt-sassify" % "1.4.2") +addSbtPlugin("org.irundaia.sbt" % "sbt-sassify" % "1.4.6") From 5197a084f458f70bba7710ccbb1590726cac883a Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Wed, 9 Nov 2016 21:25:57 -0800 Subject: [PATCH 09/70] Updated with template-control on 2016-10-29T01:14:27.353Z (#5) **/build.properties: sbt.version=0.13.13 --- project/build.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/build.properties b/project/build.properties index e5d830125..5fc1be7e4 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1,4 +1,4 @@ #Activator-generated Properties #Sat Mar 19 19:05:01 PDT 2016 template.uuid=1c7228d8-76c3-4463-9912-bafe53ed6b37 -sbt.version=0.13.12 +sbt.version=0.13.13 From 5db9801848cd6b994f09525ed4fe0d184ab34118 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Wed, 9 Nov 2016 21:28:49 -0800 Subject: [PATCH 10/70] Updated with template-control on 2016-11-10T05:26:36.999Z (#6) /LICENSE: wrote /LICENSE --- LICENSE | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/LICENSE b/LICENSE index 4baedcb95..b018ae2bc 100644 --- a/LICENSE +++ b/LICENSE @@ -1,8 +1,7 @@ -This software is licensed under the Apache 2 license, quoted below. +License +------- +Written in 2016 by Lightbend -Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project except in compliance with -the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +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. -Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an -"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific -language governing permissions and limitations under the License. \ No newline at end of file +You should have received a copy of the CC0 Public Domain Dedication along with this software. If not, see . From cb0ac987d3a0c2b12aa57a1895f93f974edc2973 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Fri, 18 Nov 2016 16:34:39 -0800 Subject: [PATCH 11/70] Updated with template-control on 2016-11-19T00:08:10.634Z (#7) /LICENSE: wrote /LICENSE **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.10") --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index f464ea705..3566c8711 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.9") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.10") // web plugins From 8782bc7b4536d105f71c1e233e132aabdad46767 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Tue, 7 Feb 2017 15:16:03 -0800 Subject: [PATCH 12/70] Updated with template-control on 2017-02-07T23:15:12.811Z (#8) /LICENSE: wrote /LICENSE **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.12") --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 3566c8711..969c5f9d3 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.10") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.12") // web plugins From 1b48941135dbffdfdbe8d7e0c31af421b3fffdf4 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Fri, 24 Feb 2017 13:26:39 -0800 Subject: [PATCH 13/70] Update to Play 2.6.x --- app/controllers/HomeController.scala | 12 +++++++++--- app/controllers/UserInfoCookieBaker.scala | 6 ++++-- build.sbt | 7 +++---- conf/application.conf | 8 +------- project/plugins.sbt | 2 +- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/app/controllers/HomeController.scala b/app/controllers/HomeController.scala index 3e013e2da..b7054f6f9 100644 --- a/app/controllers/HomeController.scala +++ b/app/controllers/HomeController.scala @@ -6,9 +6,15 @@ import play.api.mvc._ import services.user.{UserInfo, UserInfoService} @Singleton -class HomeController @Inject()(userInfoService: UserInfoService, cookieBaker: UserInfoCookieBaker) extends Controller { - - def index = Action { implicit request => +class HomeController @Inject()(userInfoService: UserInfoService, + cookieBaker: UserInfoCookieBaker, + cc: ControllerComponents) extends AbstractController(cc) { + /* + * Usually you'd do this in a custom Action and pass the action in through + * dependency injection, but for the sake of clarity, do everything in the + * controller here. + */ + def index = Action { implicit request: RequestHeader => val optionCookie = request.cookies.get(cookieBaker.COOKIE_NAME) optionCookie match { case Some(_) => diff --git a/app/controllers/UserInfoCookieBaker.scala b/app/controllers/UserInfoCookieBaker.scala index e2aa55f7f..5243568ef 100644 --- a/app/controllers/UserInfoCookieBaker.scala +++ b/app/controllers/UserInfoCookieBaker.scala @@ -7,9 +7,9 @@ import services.user.{UserInfo, UserInfoService} @Singleton class UserInfoCookieBaker @Inject()(service: UserInfoService) extends CookieBaker[UserInfo] { - override def COOKIE_NAME: String = "userInfo" + override val COOKIE_NAME: String = "userInfo" - override def isSigned = false + override val isSigned = false override def cookieSigner = { throw new IllegalStateException() } @@ -18,4 +18,6 @@ class UserInfoCookieBaker @Inject()(service: UserInfoService) extends CookieBake override protected def serialize(userInfo: UserInfo): Map[String, String] = service.encrypt(userInfo) override protected def deserialize(data: Map[String, String]): UserInfo = service.decrypt(data) + + override val path: String = "/" } diff --git a/build.sbt b/build.sbt index 41df02fc1..2952e6694 100644 --- a/build.sbt +++ b/build.sbt @@ -4,9 +4,8 @@ version := "1.0-SNAPSHOT" lazy val root = (project in file(".")).enablePlugins(PlayScala) -scalaVersion := "2.11.8" +scalaVersion := "2.12.1" +libraryDependencies += guice libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" -libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "1.5.1" % Test - -resolvers += "scalaz-bintray" at "http://dl.bintray.com/scalaz/releases" +libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "2.0.0-M2" % Test diff --git a/conf/application.conf b/conf/application.conf index e2dccbec1..7627e94fd 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -4,10 +4,4 @@ # ~~~~~ # The secret key is used to sign Play's session cookie. # This must be changed for production, but we don't recommend you change it in this file. -play.crypto.secret = "changeme" - -play.i18n { - # The application languages - langs = [ "en" ] -} - +play.http.secret.key = "changeme" diff --git a/project/plugins.sbt b/project/plugins.sbt index 969c5f9d3..4a569a564 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.12") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-M1") // web plugins From 6688a0322f535a5f84b52e97ba017f6e0a67669b Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sun, 2 Apr 2017 13:55:01 -0700 Subject: [PATCH 14/70] Upgrade branch SNAPSHOT using TemplateControl (#11) * Updated with template-control on 2017-03-28T20:57:27.600Z /LICENSE: wrote /LICENSE **/build.sbt: libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M2" % Test **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-M3") * Fix travis * Upgrades --- .travis.yml | 30 ++++++++++++++++++++ app/controllers/UserInfoCookieBaker.scala | 14 +++++---- app/services/user/UserInfoServiceImpl.scala | 2 +- build.sbt | 4 +-- project/plugins.sbt | 2 +- test/services/user/UserInfoServiceSpec.scala | 3 +- 6 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..07183c55d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,30 @@ +language: scala +# Trusty VM has 1.8u101 +# https://github.com/travis-ci/travis-ci/issues/3259#issuecomment-243534696 +dist: trusty +sudo: true +group: beta +scala: +- 2.11.8 +- 2.12.1 +jdk: +- oraclejdk8 +before_install: +- sudo add-apt-repository -y ppa:ondrej/php +- sudo apt-get -qq update +- sudo apt-get install -y libsodium-dev +cache: + directories: + - $HOME/.ivy2/cache + - $HOME/.sbt/boot/ +before_cache: + # Ensure changes to the cache aren't persisted + - rm -rf $HOME/.ivy2/cache/com.typesafe.play/* + - rm -rf $HOME/.ivy2/cache/scala_*/sbt_*/com.typesafe.play/* + # Delete all ivydata files since ivy touches them on each build + - find $HOME/.ivy2/cache -name "ivydata-*.properties" -print0 | xargs -n10 -0 rm + # Delete any SBT lock files + - find $HOME/.sbt -name "*.lock" -delete +notifications: + slack: + secure: D5Mj39P4P/mCRk/rSThkRRLKRCnv4qwY1ZMgYAljXAcJQDX2dFe7ZhUHeNHL02jGTL6QJqyM2lgKA1+yXjphmabqYB/fJmmaHZEx3c9XcfhRVdveIANdzVBfaHuM1YOzpx6LCO/6YlHHTxPMyBxl3q9ELguKs04nja0NTiKeSVAS7+rI8RUciqZ41zzq32PrdWmsPB76yxvftdHtCrTjIepnKfUfU/xvekWKybk0L6tj9P/rSu7Ao6pqJCRw8ct954dKHEY01C9tw3lyc7f9/kNsFVRq4A8+tuiGB/yM5pRHgmxbAzjQRyCUjccDgMf8P+NmSenb/JIMQcPt/ZR/dzABrzgQe225Y3b53IeuOyMO2j6ZtBUwJ27OQdXeAiY5VtUqcG3nmlrjQkSyjqWz7Frjj4Trgqlv1/9lSJs+ciwOiYcBjTew9zrVlfyqmza32VPKOvvStTrtYpSS99c4KkiOeEKZGX45yVMnqEdn4g6ox3bYj6oGst0t87KiX+rvnmPfsE1c+RfCI7M7wAieAeXQsX5PaLXQLKG0VedDJe8x6IP+zd9AxrzKSNlcjo6SsBHO1h9VrTNd/WKYkzkjeF0UwfUXqmZErz0y+bvfF77ATw4vmyagSy60a8tJjekJYDB/Qu5IXnlWrg96niKpf3j4Jcyn1CVvlTnTD0sbZM4= diff --git a/app/controllers/UserInfoCookieBaker.scala b/app/controllers/UserInfoCookieBaker.scala index 5243568ef..1277c38ad 100644 --- a/app/controllers/UserInfoCookieBaker.scala +++ b/app/controllers/UserInfoCookieBaker.scala @@ -2,16 +2,18 @@ package controllers import javax.inject.{Inject, Singleton} -import play.api.mvc.CookieBaker +import play.api.http.{JWTConfiguration, SecretConfiguration} +import play.api.mvc._ import services.user.{UserInfo, UserInfoService} @Singleton -class UserInfoCookieBaker @Inject()(service: UserInfoService) extends CookieBaker[UserInfo] { - override val COOKIE_NAME: String = "userInfo" +class UserInfoCookieBaker @Inject()(service: UserInfoService, + val secretConfiguration: SecretConfiguration) + extends CookieBaker[UserInfo] with JWTCookieDataCodec { - override val isSigned = false + override val COOKIE_NAME: String = "userInfo" - override def cookieSigner = { throw new IllegalStateException() } + override val isSigned = true override def emptyCookie: UserInfo = new UserInfo() @@ -20,4 +22,6 @@ class UserInfoCookieBaker @Inject()(service: UserInfoService) extends CookieBake override protected def deserialize(data: Map[String, String]): UserInfo = service.decrypt(data) override val path: String = "/" + + override val jwtConfiguration: JWTConfiguration = JWTConfiguration() } diff --git a/app/services/user/UserInfoServiceImpl.scala b/app/services/user/UserInfoServiceImpl.scala index cb229b079..faef44b23 100644 --- a/app/services/user/UserInfoServiceImpl.scala +++ b/app/services/user/UserInfoServiceImpl.scala @@ -40,7 +40,7 @@ class UserInfoServiceImpl @Inject()(configuration: Configuration) extends UserIn * * Storing key information confidentially and doing key rotation properly is a specialized area. Check out Daniel Somerfield's talk: Turtles All the Way Down: Storing Secrets in the Cloud and the Data Center for the details. */ - val secretHex: String = configuration.getString("user.crypto.secret").getOrElse { + val secretHex: String = configuration.getOptional[String]("user.crypto.secret").getOrElse { val randomSecret = encoder.encode(newSecretKey) logger.info(s"No secret found, creating temporary secret ${randomSecret}") randomSecret diff --git a/build.sbt b/build.sbt index 2952e6694..1f7d24f38 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -name := """play-kalium""" +name := """play-scala-kalium-example""" version := "1.0-SNAPSHOT" @@ -8,4 +8,4 @@ scalaVersion := "2.12.1" libraryDependencies += guice libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" -libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "2.0.0-M2" % Test +libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M2" % Test diff --git a/project/plugins.sbt b/project/plugins.sbt index 4a569a564..a50a5eb6e 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-M1") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-M3") // web plugins diff --git a/test/services/user/UserInfoServiceSpec.scala b/test/services/user/UserInfoServiceSpec.scala index 75e728a76..3a5f9b69f 100644 --- a/test/services/user/UserInfoServiceSpec.scala +++ b/test/services/user/UserInfoServiceSpec.scala @@ -1,8 +1,9 @@ package services.user import org.scalatestplus.play._ +import org.scalatestplus.play.guice.GuiceOneAppPerTest -class UserInfoServiceSpec extends PlaySpec with OneAppPerTest { +class UserInfoServiceSpec extends PlaySpec with GuiceOneAppPerTest { "user info service" should { From 8fc7b2b2f60d4b91c40ffcb27672b9e8faecdbf0 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Fri, 7 Apr 2017 16:51:18 -0700 Subject: [PATCH 15/70] Add session management (#12) * Add session management to kalium * Use M3 * fix tests * Break out userinfo lookup * Update readme --- README.md | 192 +----------------- app/Module.scala | 12 -- app/controllers/HomeController.scala | 109 ++++++---- app/controllers/LoginController.scala | 38 ++++ app/controllers/LogoutController.scala | 25 +++ app/controllers/UserInfoCookieBaker.scala | 38 +++- app/services/session/SessionService.scala | 39 ++++ app/services/user/UserInfoService.scala | 98 ++++++++- app/services/user/UserInfoServiceImpl.scala | 109 ---------- app/views/index.scala.html | 25 ++- build.sbt | 1 + conf/application.conf | 1 + conf/logback.xml | 6 +- conf/routes | 10 +- project/build.properties | 3 - project/plugins.sbt | 15 -- .../encryption/UserInfoServiceSpec.scala | 21 ++ test/services/user/UserInfoServiceSpec.scala | 19 -- 18 files changed, 352 insertions(+), 409 deletions(-) delete mode 100644 app/Module.scala create mode 100644 app/controllers/LoginController.scala create mode 100644 app/controllers/LogoutController.scala create mode 100644 app/services/session/SessionService.scala delete mode 100644 app/services/user/UserInfoServiceImpl.scala create mode 100644 test/services/encryption/UserInfoServiceSpec.scala delete mode 100644 test/services/user/UserInfoServiceSpec.scala diff --git a/README.md b/README.md index 52b326c3e..d9ec3f6f0 100644 --- a/README.md +++ b/README.md @@ -1,191 +1,13 @@ -# Play Kalium +# play-scala-kalium-example -This is an example application that shows how to use symmetric encryption with [Kalium](https://github.com/abstractj/kalium/). +This is an example application that shows how to use symmetric encryption with [Kalium](https://github.com/abstractj/kalium/) to do simple secure session management. -You must install libsodium before using this application. If you have homebrew, you can use `brew install libsodium`. - -The UserInfoServiceImpl class is where you'll find the symmetric encryption code. - -To use the encryption service, add something like this to a controller: - -## Controller Usage Example - -```scala -@Singleton -class HomeController @Inject()(userInfoService: UserInfoService, cookieBaker: UserInfoCookieBaker) extends Controller { - - def index = Action { implicit request => - val optionCookie = request.cookies.get(cookieBaker.COOKIE_NAME) - optionCookie match { - case Some(_) => - // We can see that the user is a terrible person, and deserves no cake, - // but the user cannot see the information in the cookie. - try { - val userInfo = cookieBaker.decodeFromCookie(optionCookie) - if (userInfo.terriblePerson) { - Ok(views.html.index(s"I'm sorry. All the cake is gone.")) - } else { - Ok(views.html.index("Hi! We have cake!")) - } - } catch { - case ex: RuntimeException if (ex.getMessage == "Decryption failed. Ciphertext failed verification") => - // This happens if you're in dev mode without a persisted secret and you - // reload the app server, because a new secret is generated but you still have the - // old cookie. - val userInfoCookie = generateUserInfoCookie - Redirect(routes.HomeController.index()).withCookies(userInfoCookie) - } - case None => - val userInfoCookie = generateUserInfoCookie - Redirect(routes.HomeController.index()).withCookies(userInfoCookie) - } - } - - private def generateUserInfoCookie: Cookie = { - // Encode information about the user that we'd rather they not know - val userInfo = UserInfo(terriblePerson = true) - val userInfoCookie = cookieBaker.encodeAsCookie(userInfo) - userInfoCookie - } -} -``` - -The CookieBaker will handle encryption and decryption automatically: - -```scala -@Singleton -class UserInfoCookieBaker @Inject()(service: UserInfoService) extends CookieBaker[UserInfo] { - override def COOKIE_NAME: String = "userInfo" - - override def isSigned = false - - override def cookieSigner = { throw new IllegalStateException() } - - override def emptyCookie: UserInfo = new UserInfo() - - override protected def serialize(userInfo: UserInfo): Map[String, String] = service.encrypt(userInfo) - - override protected def deserialize(data: Map[String, String]): UserInfo = service.decrypt(data) -} -``` - -Then the `UserInfoService` will have the settings: - -```scala -trait UserInfoService { - def decrypt(data: Map[String, String]): UserInfo - - def encrypt(userInfo: UserInfo): Map[String, String] -} - -case class UserInfo(terriblePerson: Boolean = false) +## Prerequisites -object UserInfo { - - // Use a JSON format to automatically convert between case class and JsObject - implicit val format: OFormat[UserInfo] = Json.format[UserInfo] - -} -``` - -and the actual encryption and decryption are done using SecretBox: - -```scala -@Singleton -class UserInfoServiceImpl @Inject()(configuration: Configuration) extends UserInfoService { - - private val encoder = org.abstractj.kalium.encoders.Encoder.HEX - - private val logger = org.slf4j.LoggerFactory.getLogger(this.getClass) - - // utility method for when we're showing off secret key without saving confidential info... - private def newSecretKey: Array[Byte] = { - // Key must be 32 bytes for secretbox - import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_KEYBYTES - val buf = new Array[Byte](XSALSA20_POLY1305_SECRETBOX_KEYBYTES) - new SecureRandom().nextBytes(buf) - buf - } - - private val box = { - /* - * For every service you need, you should specify a specific crypto service with its own keys. Keeping distinct - * keys per service is known as the "key separation principle". - * - * More specifically, if you have a service which encrypts user information, and another service which encrypts - * S3 credentials, they should not reuse the key. If you use the same key for both, then an attacker can cross - * reference between the encrypted values and reconstruct the key. This rule applies even if you are sharing - * the same key for hashing and encryption. - * - * Storing key information confidentially and doing key rotation properly is a specialized area. Check out Daniel Somerfield's talk: Turtles All the Way Down: Storing Secrets in the Cloud and the Data Center for the details. - */ - val secretHex: String = configuration.getString("user.crypto.secret").getOrElse { - val randomSecret = encoder.encode(newSecretKey) - logger.info(s"No secret found, creating temporary secret ${randomSecret}") - randomSecret - } - val secret = encoder.decode(secretHex) - new org.abstractj.kalium.crypto.SecretBox(secret) - } - - override def encrypt(userInfo: UserInfo): Map[String, String] = { - val nonce = Nonce.createNonce() - val json = Json.toJson(userInfo) - val stringData = Json.stringify(json) - val rawData = stringData.getBytes(StandardCharsets.UTF_8) - val cipherText = box.encrypt(nonce.raw, rawData) - - val nonceHex = encoder.encode(nonce.raw) - val cipherHex = encoder.encode(cipherText) - Map("nonce" -> nonceHex, "c" -> cipherHex) - } - - override def decrypt(data: Map[String, String]): UserInfo = { - val nonceHex = data("nonce") - val nonce = Nonce.nonceFromBytes(encoder.decode(nonceHex)) - val cipherTextHex = data("c") - val cipherText = encoder.decode(cipherTextHex) - val rawData = box.decrypt(nonce.raw, cipherText) - val stringData = new String(rawData, StandardCharsets.UTF_8) - val json = Json.parse(stringData) - val result: JsResult[UserInfo] = Json.fromJson[UserInfo](json) // uses UserInfo.format JSON magic. - result.get - } - -} - -/** - * 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. - */ -private[user] class Nonce private[UserInfoServiceImpl](val raw: Array[Byte]) extends AnyVal - -private[user] 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.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES - new Nonce(random.randomBytes(XSALSA20_POLY1305_SECRETBOX_NONCEBYTES)) - } +You must install libsodium before using this application. If you have homebrew, you can use `brew install libsodium`. - /** - * Reconstitute a nonce that has been stored with a ciphertext. - */ - def nonceFromBytes(data: Array[Byte]): Nonce = { - import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES - if (data == null || data.length != XSALSA20_POLY1305_SECRETBOX_NONCEBYTES) { - throw new IllegalArgumentException("This nonce has an invalid size: " + data.length) - } - new Nonce(data) - } +## 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. -That's it! +Sessions are managed by a key value store (here represented by Play Cache, but you would probably use Redis in production), and the only information kept on the server is the secret key used for encryption. When the user logs out, the secret key is deleted, and the encrypted information cannot be retrieved. diff --git a/app/Module.scala b/app/Module.scala deleted file mode 100644 index 33229409d..000000000 --- a/app/Module.scala +++ /dev/null @@ -1,12 +0,0 @@ -import com.google.inject.AbstractModule -import controllers.UserInfoCookieBaker -import services.user.{UserInfoService, UserInfoServiceImpl} - -class Module extends AbstractModule { - - override def configure() = { - bind(classOf[UserInfoService]).to(classOf[UserInfoServiceImpl]) - bind(classOf[UserInfoCookieBaker]).asEagerSingleton() - } - -} diff --git a/app/controllers/HomeController.scala b/app/controllers/HomeController.scala index b7054f6f9..e1f779684 100644 --- a/app/controllers/HomeController.scala +++ b/app/controllers/HomeController.scala @@ -2,49 +2,84 @@ package controllers import javax.inject._ +import play.api.i18n.{Lang, Messages, MessagesApi, MessagesProvider} import play.api.mvc._ +import services.session.SessionService import services.user.{UserInfo, UserInfoService} +import scala.concurrent.{ExecutionContext, Future} + @Singleton -class HomeController @Inject()(userInfoService: UserInfoService, - cookieBaker: UserInfoCookieBaker, +class HomeController @Inject()(userAction: UserInfoAction, + sessionService: SessionService, + userInfoService: UserInfoService, cc: ControllerComponents) extends AbstractController(cc) { - /* - * Usually you'd do this in a custom Action and pass the action in through - * dependency injection, but for the sake of clarity, do everything in the - * controller here. - */ - def index = Action { implicit request: RequestHeader => - val optionCookie = request.cookies.get(cookieBaker.COOKIE_NAME) - optionCookie match { - case Some(_) => - // We can see that the user is a terrible person, and deserves no cake, - // but the user cannot see the information in the cookie. - try { - val userInfo = cookieBaker.decodeFromCookie(optionCookie) - if (userInfo.terriblePerson) { - Ok(views.html.index(s"I'm sorry. All the cake is gone.")) - } else { - Ok(views.html.index("Hi! We have cake!")) - } - } catch { - case ex: RuntimeException if (ex.getMessage == "Decryption failed. Ciphertext failed verification") => - // This happens if you're in dev mode without a persisted secret and you - // reload the app server, because a new secret is generated but you still have the - // old cookie. - val userInfoCookie = generateUserInfoCookie - Redirect(routes.HomeController.index()).withCookies(userInfoCookie) - } - case None => - val userInfoCookie = generateUserInfoCookie - Redirect(routes.HomeController.index()).withCookies(userInfoCookie) - } + + import UserInfoForm._ + + def index = userAction { implicit request: UserRequest[AnyContent] => + Ok(views.html.index(form)) + } + +} + +object UserInfoForm { + + import play.api.data.Form + import play.api.data.Forms._ + + val form = Form( + mapping( + "username" -> text + )(UserInfo.apply)(UserInfo.unapply) + ) + +} + +object CookieStripper { + def logout(result: Result): Result = { + result.withNewSession.discardingCookies(DiscardingCookie("userInfo")) } +} + +class UserRequest[A](request: Request[A], val userInfo: Option[UserInfo], messagesApi: MessagesApi) + extends FormAwareWrappedRequest[A](request, messagesApi) + + +abstract class FormAwareWrappedRequest[A](request: Request[A], messagesApi: MessagesApi) + extends WrappedRequest[A](request) with MessagesProvider { + lazy val messages: Messages = messagesApi.preferred(request) + lazy val lang: Lang = messages.lang +} + +/** + * 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, + ec: ExecutionContext) + extends ActionBuilder[UserRequest, AnyContent] { - private def generateUserInfoCookie: Cookie = { - // Encode information about the user that we'd rather they not know - val userInfo = UserInfo(terriblePerson = true) - val userInfoCookie = cookieBaker.encodeAsCookie(userInfo) - userInfoCookie + override def parser: BodyParser[AnyContent] = playBodyParsers.anyContent + override protected def executionContext: ExecutionContext = ec + + override def invokeBlock[A](request: Request[A], block: (UserRequest[A]) => Future[Result]): Future[Result] = { + block(new UserRequest[A](request, userInfoFromRequest(request), messagesApi)) + } + + private def userInfoFromRequest(request: RequestHeader): Option[UserInfo] = { + val maybeCookieBaker = for { + sessionId <- request.session.get("sessionId") + secretKey <- sessionService.lookup(sessionId) + } yield factory.createCookieBaker(secretKey) + + maybeCookieBaker.flatMap { cookieBaker => + cookieBaker.decodeFromCookie(request.cookies.get(cookieBaker.COOKIE_NAME)) + } } + } diff --git a/app/controllers/LoginController.scala b/app/controllers/LoginController.scala new file mode 100644 index 000000000..14583e2c7 --- /dev/null +++ b/app/controllers/LoginController.scala @@ -0,0 +1,38 @@ +package controllers + +import javax.inject.{Inject, Singleton} + +import play.api.mvc.{AbstractController, AnyContent, ControllerComponents, Request} +import services.session.SessionService +import services.user.{UserInfo, UserInfoService} + + +@Singleton +class LoginController @Inject()(sessionService: SessionService, + userInfoService: UserInfoService, + factory: UserInfoCookieBakerFactory, + cc: ControllerComponents) extends AbstractController(cc) { + + def login = Action { implicit request: Request[AnyContent] => + def successFunc = { userInfo: UserInfo => + val secretKey = userInfoService.newSecretKey + val sessionId = sessionService.create(secretKey) + + // Session id and user info are distinct cookies. The user info lives as long as you + // have a secret key for it. The session dies when the browser closes or you logout. + val cookieBaker = factory.createCookieBaker(secretKey) + val userInfoCookie = cookieBaker.encodeAsCookie(Some(userInfo)) + val session = request.session + ("sessionId" -> sessionId) + + play.api.Logger.info("Created a new username " + userInfo) + + Redirect(routes.HomeController.index()).withSession(session).withCookies(userInfoCookie) + } + + UserInfoForm.form.bindFromRequest().fold({ form => + play.api.Logger.error("could not log in!") + Redirect(routes.HomeController.index()).flashing("error" -> "Could not login!") + }, successFunc) + } + +} diff --git a/app/controllers/LogoutController.scala b/app/controllers/LogoutController.scala new file mode 100644 index 000000000..6e945810e --- /dev/null +++ b/app/controllers/LogoutController.scala @@ -0,0 +1,25 @@ +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("sessionId").foreach { sessionId => + sessionService.delete(sessionId) + } + + CookieStripper.logout { + Redirect(routes.HomeController.index()) + } + } + +} diff --git a/app/controllers/UserInfoCookieBaker.scala b/app/controllers/UserInfoCookieBaker.scala index 1277c38ad..d7955de90 100644 --- a/app/controllers/UserInfoCookieBaker.scala +++ b/app/controllers/UserInfoCookieBaker.scala @@ -6,22 +6,44 @@ import play.api.http.{JWTConfiguration, SecretConfiguration} import play.api.mvc._ import services.user.{UserInfo, UserInfoService} -@Singleton -class UserInfoCookieBaker @Inject()(service: UserInfoService, - val secretConfiguration: SecretConfiguration) - extends CookieBaker[UserInfo] with JWTCookieDataCodec { +import scala.concurrent.duration._ + +class UserInfoCookieBaker(secretKey: Array[Byte], + userInfoService: UserInfoService, + val secretConfiguration: SecretConfiguration) + extends CookieBaker[Option[UserInfo]] with JWTCookieDataCodec { + + private val expirationDate = 365.days override val COOKIE_NAME: String = "userInfo" override val isSigned = true - override def emptyCookie: UserInfo = new UserInfo() + override def emptyCookie: Option[UserInfo] = None + + override val maxAge: Option[Int] = Some(expirationDate.toSeconds.toInt) - override protected def serialize(userInfo: UserInfo): Map[String, String] = service.encrypt(userInfo) + override protected def serialize(userInfo: Option[UserInfo]): Map[String, String] = { + userInfoService.encrypt(secretKey, userInfo) + } - override protected def deserialize(data: Map[String, String]): UserInfo = service.decrypt(data) + override protected def deserialize(data: Map[String, String]): Option[UserInfo] = { + userInfoService.decrypt(secretKey, data) + } override val path: String = "/" - override val jwtConfiguration: JWTConfiguration = JWTConfiguration() + override val jwtConfiguration: JWTConfiguration = JWTConfiguration(expiresAfter = Some(expirationDate)) +} + +/** + * Hide the cookie baker dependencies behind a factory + */ +@Singleton +class UserInfoCookieBakerFactory @Inject()(userInfoService: UserInfoService, + secretConfiguration: SecretConfiguration) { + + def createCookieBaker(secretKey: Array[Byte]): UserInfoCookieBaker = { + new UserInfoCookieBaker(secretKey, userInfoService, secretConfiguration) + } } diff --git a/app/services/session/SessionService.scala b/app/services/session/SessionService.scala new file mode 100644 index 000000000..c1df2dffc --- /dev/null +++ b/app/services/session/SessionService.scala @@ -0,0 +1,39 @@ +package services.session + +import javax.inject.{Inject, Singleton} + +import play.api.cache.SyncCacheApi + +/** + * A session service that ties session id to secret key. This would probably be a + * distributed key value store like Redis or Cassandra in a production system. + * + * @param cache + */ +@Singleton +class SessionService @Inject()(cache: SyncCacheApi) { + + def create(secretKey: Array[Byte]): String = { + val sessionId = newSessionId() + cache.set(sessionId, secretKey) + sessionId + } + + def lookup(sessionId: String): Option[Array[Byte]] = { + cache.get[Array[Byte]](sessionId) + } + + def put(sessionId: String, sessionKey: Array[Byte]): Unit = { + cache.set(sessionId, sessionKey) + } + + def delete(sessionId: String): Unit = { + cache.remove(sessionId) + } + + private val sr = new java.security.SecureRandom() + + private def newSessionId(): String = { + new java.math.BigInteger(130, sr).toString(32) + } +} diff --git a/app/services/user/UserInfoService.scala b/app/services/user/UserInfoService.scala index ee8ae9af4..467cc7ac1 100644 --- a/app/services/user/UserInfoService.scala +++ b/app/services/user/UserInfoService.scala @@ -1,17 +1,15 @@ package services.user -import play.api.libs.json.{Json, OFormat} +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import javax.inject.{Inject, Singleton} -/** - * Defines a user info service trait that encrypts and decrypts user infos. - */ -trait UserInfoService { - def decrypt(data: Map[String, String]): UserInfo +import org.abstractj.kalium.crypto.Random +import play.api.Configuration +import play.api.libs.json.{JsResult, Json, OFormat} - def encrypt(userInfo: UserInfo): Map[String, String] -} -case class UserInfo(terriblePerson: Boolean = false) +case class UserInfo(username: String) object UserInfo { @@ -19,3 +17,85 @@ object UserInfo { implicit val format: OFormat[UserInfo] = Json.format[UserInfo] } + +/** + * Implementation of user info service. + */ +@Singleton +class UserInfoService @Inject()(configuration: Configuration) { + + private val encoder = org.abstractj.kalium.encoders.Encoder.HEX + + private val logger = org.slf4j.LoggerFactory.getLogger(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.XSALSA20_POLY1305_SECRETBOX_KEYBYTES + val buf = new Array[Byte](XSALSA20_POLY1305_SECRETBOX_KEYBYTES) + new SecureRandom().nextBytes(buf) + buf + } + + private def box(secretKey: Array[Byte]) = { + new org.abstractj.kalium.crypto.SecretBox(secretKey) + } + + def encrypt(secretKey: Array[Byte], userInfo: Option[UserInfo]): Map[String, String] = { + val nonce = Nonce.createNonce() + val json = Json.toJson(userInfo) + val stringData = Json.stringify(json) + 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(secretKey: Array[Byte], data: Map[String, String]): Option[UserInfo] = { + 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: JsResult[UserInfo] = Json.fromJson[UserInfo](json) // uses UserInfo.format JSON magic. + result.asOpt + } + +} + +/** + * 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. + */ +private[user] class Nonce(val raw: Array[Byte]) extends AnyVal + +private[user] 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.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES + new Nonce(random.randomBytes(XSALSA20_POLY1305_SECRETBOX_NONCEBYTES)) + } + + /** + * Reconstitute a nonce that has been stored with a ciphertext. + */ + def nonceFromBytes(data: Array[Byte]): Nonce = { + import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES + if (data == null || data.length != XSALSA20_POLY1305_SECRETBOX_NONCEBYTES) { + throw new IllegalArgumentException("This nonce has an invalid size: " + data.length) + } + new Nonce(data) + } + +} diff --git a/app/services/user/UserInfoServiceImpl.scala b/app/services/user/UserInfoServiceImpl.scala deleted file mode 100644 index faef44b23..000000000 --- a/app/services/user/UserInfoServiceImpl.scala +++ /dev/null @@ -1,109 +0,0 @@ -package services.user - -import java.nio.charset.StandardCharsets -import java.security.SecureRandom -import javax.inject.{Inject, Singleton} - -import org.abstractj.kalium.crypto.Random -import play.api.Configuration -import play.api.libs.json.{JsResult, Json} - - -/** - * Implementation of user info service. - */ -@Singleton -class UserInfoServiceImpl @Inject()(configuration: Configuration) extends UserInfoService { - - private val encoder = org.abstractj.kalium.encoders.Encoder.HEX - - private val logger = org.slf4j.LoggerFactory.getLogger(this.getClass) - - // utility method for when we're showing off secret key without saving confidential info... - private def newSecretKey: Array[Byte] = { - // Key must be 32 bytes for secretbox - import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_KEYBYTES - val buf = new Array[Byte](XSALSA20_POLY1305_SECRETBOX_KEYBYTES) - new SecureRandom().nextBytes(buf) - buf - } - - private val box = { - /* - * For every service you need, you should specify a specific crypto service with its own keys. Keeping distinct - * keys per service is known as the "key separation principle". - * - * More specifically, if you have a service which encrypts user information, and another service which encrypts - * S3 credentials, they should not reuse the key. If you use the same key for both, then an attacker can cross - * reference between the encrypted values and reconstruct the key. This rule applies even if you are sharing - * the same key for hashing and encryption. - * - * Storing key information confidentially and doing key rotation properly is a specialized area. Check out Daniel Somerfield's talk: Turtles All the Way Down: Storing Secrets in the Cloud and the Data Center for the details. - */ - val secretHex: String = configuration.getOptional[String]("user.crypto.secret").getOrElse { - val randomSecret = encoder.encode(newSecretKey) - logger.info(s"No secret found, creating temporary secret ${randomSecret}") - randomSecret - } - val secret = encoder.decode(secretHex) - new org.abstractj.kalium.crypto.SecretBox(secret) - } - - override def encrypt(userInfo: UserInfo): Map[String, String] = { - val nonce = Nonce.createNonce() - val json = Json.toJson(userInfo) - val stringData = Json.stringify(json) - val rawData = stringData.getBytes(StandardCharsets.UTF_8) - val cipherText = box.encrypt(nonce.raw, rawData) - - val nonceHex = encoder.encode(nonce.raw) - val cipherHex = encoder.encode(cipherText) - Map("nonce" -> nonceHex, "c" -> cipherHex) - } - - override def decrypt(data: Map[String, String]): UserInfo = { - val nonceHex = data("nonce") - val nonce = Nonce.nonceFromBytes(encoder.decode(nonceHex)) - val cipherTextHex = data("c") - val cipherText = encoder.decode(cipherTextHex) - val rawData = box.decrypt(nonce.raw, cipherText) - val stringData = new String(rawData, StandardCharsets.UTF_8) - val json = Json.parse(stringData) - val result: JsResult[UserInfo] = Json.fromJson[UserInfo](json) // uses UserInfo.format JSON magic. - result.get - } - -} - -/** - * 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. - */ -private[user] class Nonce(val raw: Array[Byte]) extends AnyVal - -private[user] 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.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES - new Nonce(random.randomBytes(XSALSA20_POLY1305_SECRETBOX_NONCEBYTES)) - } - - /** - * Reconstitute a nonce that has been stored with a ciphertext. - */ - def nonceFromBytes(data: Array[Byte]): Nonce = { - import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES - if (data == null || data.length != XSALSA20_POLY1305_SECRETBOX_NONCEBYTES) { - throw new IllegalArgumentException("This nonce has an invalid size: " + data.length) - } - new Nonce(data) - } - -} diff --git a/app/views/index.scala.html b/app/views/index.scala.html index 4a11b7bb2..3bdbbf5e0 100644 --- a/app/views/index.scala.html +++ b/app/views/index.scala.html @@ -1,5 +1,26 @@ -@(userMessage: String) +@import services.user.UserInfo +@(form: Form[UserInfo])(implicit request: UserRequest[_]) @main("Play-Kalium") { - @userMessage (See HomeController.scala for why you got this message) + +

+ 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/build.sbt b/build.sbt index 1f7d24f38..2247d164d 100644 --- a/build.sbt +++ b/build.sbt @@ -7,5 +7,6 @@ lazy val root = (project in file(".")).enablePlugins(PlayScala) scalaVersion := "2.12.1" libraryDependencies += guice +libraryDependencies += cache libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M2" % Test diff --git a/conf/application.conf b/conf/application.conf index 7627e94fd..eb761a368 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -5,3 +5,4 @@ # The secret key is used to sign Play's session cookie. # This must be changed for production, but we don't recommend you change it in this file. play.http.secret.key = "changeme" + diff --git a/conf/logback.xml b/conf/logback.xml index 449ffab37..8979d5283 100644 --- a/conf/logback.xml +++ b/conf/logback.xml @@ -24,11 +24,7 @@ - - - - - + diff --git a/conf/routes b/conf/routes index 18027a30a..8ad784d85 100644 --- a/conf/routes +++ b/conf/routes @@ -1,9 +1,9 @@ -# Routes -# This file defines all application routes (Higher priority routes first) -# ~~~~ - -# An example controller showing a sample home page 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/project/build.properties b/project/build.properties index 5fc1be7e4..27e88aa11 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1,4 +1 @@ -#Activator-generated Properties -#Sat Mar 19 19:05:01 PDT 2016 -template.uuid=1c7228d8-76c3-4463-9912-bafe53ed6b37 sbt.version=0.13.13 diff --git a/project/plugins.sbt b/project/plugins.sbt index a50a5eb6e..0f364291a 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,18 +1,3 @@ // The Play plugin addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-M3") -// web plugins - -addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.0") - -addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.0") - -addSbtPlugin("com.typesafe.sbt" % "sbt-jshint" % "1.0.4") - -addSbtPlugin("com.typesafe.sbt" % "sbt-rjs" % "1.0.8") - -addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.1") - -addSbtPlugin("com.typesafe.sbt" % "sbt-mocha" % "1.1.0") - -addSbtPlugin("org.irundaia.sbt" % "sbt-sassify" % "1.4.6") diff --git a/test/services/encryption/UserInfoServiceSpec.scala b/test/services/encryption/UserInfoServiceSpec.scala new file mode 100644 index 000000000..c239ddf41 --- /dev/null +++ b/test/services/encryption/UserInfoServiceSpec.scala @@ -0,0 +1,21 @@ +package services.encryption + +import org.scalatestplus.play._ +import org.scalatestplus.play.guice.GuiceOneAppPerTest +import services.user.{UserInfo, UserInfoService} + +class UserInfoServiceSpec extends PlaySpec with GuiceOneAppPerTest { + + "encryption info service" should { + + "symmetrically encrypt data" in { + val service = app.injector.instanceOf(classOf[UserInfoService]) + val secretKey = service.newSecretKey + val encryptedMap = service.encrypt(secretKey, Option(UserInfo(username = "will"))) + val decryptedUserInfo = service.decrypt(secretKey, encryptedMap) + decryptedUserInfo mustBe Some(UserInfo("will")) + } + + } + +} diff --git a/test/services/user/UserInfoServiceSpec.scala b/test/services/user/UserInfoServiceSpec.scala deleted file mode 100644 index 3a5f9b69f..000000000 --- a/test/services/user/UserInfoServiceSpec.scala +++ /dev/null @@ -1,19 +0,0 @@ -package services.user - -import org.scalatestplus.play._ -import org.scalatestplus.play.guice.GuiceOneAppPerTest - -class UserInfoServiceSpec extends PlaySpec with GuiceOneAppPerTest { - - "user info service" should { - - "symmetrically encrypt data" in { - val service = app.injector.instanceOf(classOf[UserInfoServiceImpl]) - val encryptedMap = service.encrypt(UserInfo(terriblePerson = true)) - val decryptedUserInfo = service.decrypt(encryptedMap) - decryptedUserInfo.terriblePerson mustBe true - } - - } - -} From 5e723e4bbbac33116fa764d465eb64541ee6ce76 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sat, 8 Apr 2017 11:41:06 -0700 Subject: [PATCH 16/70] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d9ec3f6f0..22fe32f12 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# play-scala-kalium-example +# play-scala-secure-session-example -This is an example application that shows how to use symmetric encryption with [Kalium](https://github.com/abstractj/kalium/) to do simple secure session management. +This is an example application that shows how to use symmetric encryption with [Kalium](https://github.com/abstractj/kalium/) to do simple secure session management in Play, using the Scala API and session cookies. ## Prerequisites @@ -10,4 +10,4 @@ You must install libsodium before using this application. If you have homebrew, 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. -Sessions are managed by a key value store (here represented by Play Cache, but you would probably use Redis in production), and the only information kept on the server is the secret key used for encryption. When the user logs out, the secret key is deleted, and the encrypted information cannot be retrieved. +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. From 98307404e20d6ca1e09402477a947f64565e0796 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sat, 8 Apr 2017 11:42:31 -0700 Subject: [PATCH 17/70] Update build.sbt --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 2247d164d..8d78e1d18 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -name := """play-scala-kalium-example""" +name := """play-scala-secure-session-example""" version := "1.0-SNAPSHOT" From 34a6ad4215b1f3e14b9958b184445e2678abe65b Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sat, 8 Apr 2017 20:54:57 -0700 Subject: [PATCH 18/70] Generic cleanup (#13) * Cleanup controllers * More cleanup * Yet more cleanup --- app/controllers/HomeController.scala | 72 +--------- app/controllers/LoginController.scala | 39 +++--- app/controllers/LogoutController.scala | 6 +- app/controllers/UserInfoCookieBaker.scala | 49 ------- app/controllers/package.scala | 126 ++++++++++++++++++ .../encryption/EncryptedCookieBaker.scala | 37 +++++ .../encryption/EncryptionService.scala | 63 +++++++++ app/services/encryption/Nonce.scala | 36 +++++ app/services/session/SessionService.scala | 2 +- app/services/user/UserInfoService.scala | 101 -------------- app/views/index.scala.html | 3 +- .../encryption/EncryptionServiceSpec.scala | 28 ++++ .../encryption/UserInfoServiceSpec.scala | 21 --- 13 files changed, 311 insertions(+), 272 deletions(-) delete mode 100644 app/controllers/UserInfoCookieBaker.scala create mode 100644 app/controllers/package.scala create mode 100644 app/services/encryption/EncryptedCookieBaker.scala create mode 100644 app/services/encryption/EncryptionService.scala create mode 100644 app/services/encryption/Nonce.scala delete mode 100644 app/services/user/UserInfoService.scala create mode 100644 test/services/encryption/EncryptionServiceSpec.scala delete mode 100644 test/services/encryption/UserInfoServiceSpec.scala diff --git a/app/controllers/HomeController.scala b/app/controllers/HomeController.scala index e1f779684..6158d489f 100644 --- a/app/controllers/HomeController.scala +++ b/app/controllers/HomeController.scala @@ -2,84 +2,14 @@ package controllers import javax.inject._ -import play.api.i18n.{Lang, Messages, MessagesApi, MessagesProvider} import play.api.mvc._ -import services.session.SessionService -import services.user.{UserInfo, UserInfoService} - -import scala.concurrent.{ExecutionContext, Future} @Singleton class HomeController @Inject()(userAction: UserInfoAction, - sessionService: SessionService, - userInfoService: UserInfoService, cc: ControllerComponents) extends AbstractController(cc) { - import UserInfoForm._ - - def index = userAction { implicit request: UserRequest[AnyContent] => + def index = userAction { implicit request: UserRequest[_] => Ok(views.html.index(form)) } } - -object UserInfoForm { - - import play.api.data.Form - import play.api.data.Forms._ - - val form = Form( - mapping( - "username" -> text - )(UserInfo.apply)(UserInfo.unapply) - ) - -} - -object CookieStripper { - def logout(result: Result): Result = { - result.withNewSession.discardingCookies(DiscardingCookie("userInfo")) - } -} - -class UserRequest[A](request: Request[A], val userInfo: Option[UserInfo], messagesApi: MessagesApi) - extends FormAwareWrappedRequest[A](request, messagesApi) - - -abstract class FormAwareWrappedRequest[A](request: Request[A], messagesApi: MessagesApi) - extends WrappedRequest[A](request) with MessagesProvider { - lazy val messages: Messages = messagesApi.preferred(request) - lazy val lang: Lang = messages.lang -} - -/** - * 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, - ec: ExecutionContext) - extends ActionBuilder[UserRequest, AnyContent] { - - override def parser: BodyParser[AnyContent] = playBodyParsers.anyContent - override protected def executionContext: ExecutionContext = ec - - override def invokeBlock[A](request: Request[A], block: (UserRequest[A]) => Future[Result]): Future[Result] = { - block(new UserRequest[A](request, userInfoFromRequest(request), messagesApi)) - } - - private def userInfoFromRequest(request: RequestHeader): Option[UserInfo] = { - val maybeCookieBaker = for { - sessionId <- request.session.get("sessionId") - secretKey <- sessionService.lookup(sessionId) - } yield factory.createCookieBaker(secretKey) - - maybeCookieBaker.flatMap { cookieBaker => - cookieBaker.decodeFromCookie(request.cookies.get(cookieBaker.COOKIE_NAME)) - } - } - -} diff --git a/app/controllers/LoginController.scala b/app/controllers/LoginController.scala index 14583e2c7..750152b9f 100644 --- a/app/controllers/LoginController.scala +++ b/app/controllers/LoginController.scala @@ -2,37 +2,28 @@ package controllers import javax.inject.{Inject, Singleton} -import play.api.mvc.{AbstractController, AnyContent, ControllerComponents, Request} -import services.session.SessionService -import services.user.{UserInfo, UserInfoService} - +import play.api.data.Form +import play.api.mvc._ @Singleton -class LoginController @Inject()(sessionService: SessionService, - userInfoService: UserInfoService, - factory: UserInfoCookieBakerFactory, +class LoginController @Inject()(action: UserInfoAction, + sessionGenerator: SessionGenerator, cc: ControllerComponents) extends AbstractController(cc) { - def login = Action { implicit request: Request[AnyContent] => - def successFunc = { userInfo: UserInfo => - val secretKey = userInfoService.newSecretKey - val sessionId = sessionService.create(secretKey) - - // Session id and user info are distinct cookies. The user info lives as long as you - // have a secret key for it. The session dies when the browser closes or you logout. - val cookieBaker = factory.createCookieBaker(secretKey) - val userInfoCookie = cookieBaker.encodeAsCookie(Some(userInfo)) - val session = request.session + ("sessionId" -> sessionId) - - play.api.Logger.info("Created a new username " + userInfo) + def login = action { implicit request: UserRequest[AnyContent] => + val successFunc = { userInfo: UserInfo => + val (sessionId, encryptedCookie) = sessionGenerator.createSession(userInfo) + val session = request.session + (SESSION_ID -> sessionId) + Redirect(routes.HomeController.index()) + .withSession(session) + .withCookies(encryptedCookie) + } - Redirect(routes.HomeController.index()).withSession(session).withCookies(userInfoCookie) + val errorFunc = { badForm: Form[UserInfo] => + BadRequest(views.html.index(badForm)).flashing(FLASH_ERROR -> "Could not login!") } - UserInfoForm.form.bindFromRequest().fold({ form => - play.api.Logger.error("could not log in!") - Redirect(routes.HomeController.index()).flashing("error" -> "Could not login!") - }, successFunc) + form.bindFromRequest().fold(errorFunc, successFunc) } } diff --git a/app/controllers/LogoutController.scala b/app/controllers/LogoutController.scala index 6e945810e..ebf2363d7 100644 --- a/app/controllers/LogoutController.scala +++ b/app/controllers/LogoutController.scala @@ -10,14 +10,14 @@ class LogoutController @Inject()(sessionService: SessionService, cc: ControllerComponents) extends AbstractController(cc) { - def logout = Action { implicit request: Request[AnyContent] => + 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("sessionId").foreach { sessionId => + request.session.get(SESSION_ID).foreach { sessionId => sessionService.delete(sessionId) } - CookieStripper.logout { + discardingSession { Redirect(routes.HomeController.index()) } } diff --git a/app/controllers/UserInfoCookieBaker.scala b/app/controllers/UserInfoCookieBaker.scala deleted file mode 100644 index d7955de90..000000000 --- a/app/controllers/UserInfoCookieBaker.scala +++ /dev/null @@ -1,49 +0,0 @@ -package controllers - -import javax.inject.{Inject, Singleton} - -import play.api.http.{JWTConfiguration, SecretConfiguration} -import play.api.mvc._ -import services.user.{UserInfo, UserInfoService} - -import scala.concurrent.duration._ - -class UserInfoCookieBaker(secretKey: Array[Byte], - userInfoService: UserInfoService, - val secretConfiguration: SecretConfiguration) - extends CookieBaker[Option[UserInfo]] with JWTCookieDataCodec { - - private val expirationDate = 365.days - - override val COOKIE_NAME: String = "userInfo" - - override val isSigned = true - - override def emptyCookie: Option[UserInfo] = None - - override val maxAge: Option[Int] = Some(expirationDate.toSeconds.toInt) - - override protected def serialize(userInfo: Option[UserInfo]): Map[String, String] = { - userInfoService.encrypt(secretKey, userInfo) - } - - override protected def deserialize(data: Map[String, String]): Option[UserInfo] = { - userInfoService.decrypt(secretKey, data) - } - - override val path: String = "/" - - override val jwtConfiguration: JWTConfiguration = JWTConfiguration(expiresAfter = Some(expirationDate)) -} - -/** - * Hide the cookie baker dependencies behind a factory - */ -@Singleton -class UserInfoCookieBakerFactory @Inject()(userInfoService: UserInfoService, - secretConfiguration: SecretConfiguration) { - - def createCookieBaker(secretKey: Array[Byte]): UserInfoCookieBaker = { - new UserInfoCookieBaker(secretKey, userInfoService, secretConfiguration) - } -} diff --git a/app/controllers/package.scala b/app/controllers/package.scala new file mode 100644 index 000000000..a858f2736 --- /dev/null +++ b/app/controllers/package.scala @@ -0,0 +1,126 @@ +import java.time.Clock +import javax.inject.{Inject, Singleton} + +import play.api.http.SecretConfiguration +import play.api.i18n.{Messages, MessagesApi, MessagesProvider} +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" + + 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("userInfo")) + } + + /** + * 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, + ec: ExecutionContext) + extends ActionBuilder[UserRequest, AnyContent] { + + private val clock = Clock.systemUTC() + + override def parser: BodyParser[AnyContent] = playBodyParsers.anyContent + + override def invokeBlock[A](request: Request[A], block: (UserRequest[A]) => Future[Result]): Future[Result] = { + block(userRequestFromRequest(request)) + } + + private def userRequestFromRequest[A](request: Request[A]) = { + new UserRequest[A](request, userInfoFromRequest(request), messagesApi) + } + + private def userInfoFromRequest(request: RequestHeader): Option[UserInfo] = { + val maybeCookieBaker = for { + sessionId <- request.session.get(SESSION_ID) + secretKey <- sessionService.lookup(sessionId) + } yield factory.createCookieBaker(secretKey) + + maybeCookieBaker.flatMap { cookieBaker => + cookieBaker.decodeFromCookie(request.cookies.get(cookieBaker.COOKIE_NAME)) + } + } + + override protected def executionContext: ExecutionContext = ec + } + + // Minimum work needed to avoid using I18nController + trait FormRequestHeader extends MessagesProvider { self: RequestHeader => + def messagesApi: MessagesApi + lazy val messages: Messages = messagesApi.preferred(self) + } + + class UserRequest[A](request: Request[A], + val userInfo: Option[UserInfo], + val messagesApi: MessagesApi) + extends WrappedRequest[A](request) with FormRequestHeader + + /** + * 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 = "userInfo" + } + } + } + + @Singleton + class SessionGenerator @Inject()(sessionService: SessionService, + userInfoService: EncryptionService, + factory: UserInfoCookieBakerFactory) { + + def createSession(userInfo: UserInfo): (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 + val sessionId = sessionService.create(secretKey) + (sessionId, userInfoCookie) + } + + } + +} diff --git a/app/services/encryption/EncryptedCookieBaker.scala b/app/services/encryption/EncryptedCookieBaker.scala new file mode 100644 index 000000000..34e296836 --- /dev/null +++ b/app/services/encryption/EncryptedCookieBaker.scala @@ -0,0 +1,37 @@ +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/app/services/encryption/EncryptionService.scala b/app/services/encryption/EncryptionService.scala new file mode 100644 index 000000000..fe6190154 --- /dev/null +++ b/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.XSALSA20_POLY1305_SECRETBOX_KEYBYTES + val buf = new Array[Byte](XSALSA20_POLY1305_SECRETBOX_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: 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: JsResult[A] = Json.fromJson[A](json) + logger.info(s"decrypt: json = $json, result = $result") + result.asOpt + } + + 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/app/services/encryption/Nonce.scala b/app/services/encryption/Nonce.scala new file mode 100644 index 000000000..4d8d7c8ff --- /dev/null +++ b/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.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES + new Nonce(random.randomBytes(XSALSA20_POLY1305_SECRETBOX_NONCEBYTES)) + } + + /** + * Reconstitute a nonce that has been stored with a ciphertext. + */ + def nonceFromBytes(data: Array[Byte]): Nonce = { + import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES + if (data == null || data.length != XSALSA20_POLY1305_SECRETBOX_NONCEBYTES) { + throw new IllegalArgumentException("This nonce has an invalid size: " + data.length) + } + new Nonce(data) + } + +} diff --git a/app/services/session/SessionService.scala b/app/services/session/SessionService.scala index c1df2dffc..d3474b993 100644 --- a/app/services/session/SessionService.scala +++ b/app/services/session/SessionService.scala @@ -6,7 +6,7 @@ import play.api.cache.SyncCacheApi /** * A session service that ties session id to secret key. This would probably be a - * distributed key value store like Redis or Cassandra in a production system. + * key value store like Redis or Cassandra in a production system. * * @param cache */ diff --git a/app/services/user/UserInfoService.scala b/app/services/user/UserInfoService.scala deleted file mode 100644 index 467cc7ac1..000000000 --- a/app/services/user/UserInfoService.scala +++ /dev/null @@ -1,101 +0,0 @@ -package services.user - -import java.nio.charset.StandardCharsets -import java.security.SecureRandom -import javax.inject.{Inject, Singleton} - -import org.abstractj.kalium.crypto.Random -import play.api.Configuration -import play.api.libs.json.{JsResult, Json, OFormat} - - -case class UserInfo(username: String) - -object UserInfo { - - // Use a JSON format to automatically convert between case class and JsObject - implicit val format: OFormat[UserInfo] = Json.format[UserInfo] - -} - -/** - * Implementation of user info service. - */ -@Singleton -class UserInfoService @Inject()(configuration: Configuration) { - - private val encoder = org.abstractj.kalium.encoders.Encoder.HEX - - private val logger = org.slf4j.LoggerFactory.getLogger(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.XSALSA20_POLY1305_SECRETBOX_KEYBYTES - val buf = new Array[Byte](XSALSA20_POLY1305_SECRETBOX_KEYBYTES) - new SecureRandom().nextBytes(buf) - buf - } - - private def box(secretKey: Array[Byte]) = { - new org.abstractj.kalium.crypto.SecretBox(secretKey) - } - - def encrypt(secretKey: Array[Byte], userInfo: Option[UserInfo]): Map[String, String] = { - val nonce = Nonce.createNonce() - val json = Json.toJson(userInfo) - val stringData = Json.stringify(json) - 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(secretKey: Array[Byte], data: Map[String, String]): Option[UserInfo] = { - 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: JsResult[UserInfo] = Json.fromJson[UserInfo](json) // uses UserInfo.format JSON magic. - result.asOpt - } - -} - -/** - * 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. - */ -private[user] class Nonce(val raw: Array[Byte]) extends AnyVal - -private[user] 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.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES - new Nonce(random.randomBytes(XSALSA20_POLY1305_SECRETBOX_NONCEBYTES)) - } - - /** - * Reconstitute a nonce that has been stored with a ciphertext. - */ - def nonceFromBytes(data: Array[Byte]): Nonce = { - import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES - if (data == null || data.length != XSALSA20_POLY1305_SECRETBOX_NONCEBYTES) { - throw new IllegalArgumentException("This nonce has an invalid size: " + data.length) - } - new Nonce(data) - } - -} diff --git a/app/views/index.scala.html b/app/views/index.scala.html index 3bdbbf5e0..6f1929744 100644 --- a/app/views/index.scala.html +++ b/app/views/index.scala.html @@ -1,7 +1,6 @@ -@import services.user.UserInfo @(form: Form[UserInfo])(implicit request: UserRequest[_]) -@main("Play-Kalium") { +@main("play-scala-secure-session-example") {

Username is @{request.userInfo.map(_.username).getOrElse("undefined")} diff --git a/test/services/encryption/EncryptionServiceSpec.scala b/test/services/encryption/EncryptionServiceSpec.scala new file mode 100644 index 000000000..ad6409add --- /dev/null +++ b/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)) + } + + } + +} diff --git a/test/services/encryption/UserInfoServiceSpec.scala b/test/services/encryption/UserInfoServiceSpec.scala deleted file mode 100644 index c239ddf41..000000000 --- a/test/services/encryption/UserInfoServiceSpec.scala +++ /dev/null @@ -1,21 +0,0 @@ -package services.encryption - -import org.scalatestplus.play._ -import org.scalatestplus.play.guice.GuiceOneAppPerTest -import services.user.{UserInfo, UserInfoService} - -class UserInfoServiceSpec extends PlaySpec with GuiceOneAppPerTest { - - "encryption info service" should { - - "symmetrically encrypt data" in { - val service = app.injector.instanceOf(classOf[UserInfoService]) - val secretKey = service.newSecretKey - val encryptedMap = service.encrypt(secretKey, Option(UserInfo(username = "will"))) - val decryptedUserInfo = service.decrypt(secretKey, encryptedMap) - decryptedUserInfo mustBe Some(UserInfo("will")) - } - - } - -} From e271727b8e6891ce97e3748638f7c31bd0a16cd8 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sun, 9 Apr 2017 10:02:19 -0700 Subject: [PATCH 19/70] Update README.md --- README.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 22fe32f12..b12161177 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,25 @@ # play-scala-secure-session-example -This is an example application that shows how to use symmetric encryption with [Kalium](https://github.com/abstractj/kalium/) to do simple secure session management in Play, using the Scala API and session cookies. +This is an example application that shows how to use symmetric encryption with [Kalium](https://github.com/abstractj/kalium/), a Java wrapper around libsodium, 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 You must install libsodium before using this application. If you have homebrew, you can use `brew install libsodium`. -## Overview +You must have JDK 1.8 and [sbt](http://www.scala-sbt.org/) installed. -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. +## Running -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. +Run sbt from the command line: + +``` +sbt run +``` + +Then go to http://localhost:9000 to see the server. From 0c59c4f2dfd2bf9713f312840dad1a7fca9b1f43 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sun, 9 Apr 2017 15:46:18 -0700 Subject: [PATCH 20/70] Add distributed caching (#15) --- app/Module.scala | 10 ++++ app/controllers/LoginController.scala | 22 +++++--- app/controllers/package.scala | 50 +++++++++---------- app/services/session/ClusterSystem.scala | 36 ++++++++++++++ app/services/session/ReplicatedCache.scala | 58 ++++++++++++++++++++++ app/services/session/SessionService.scala | 41 +++++++++------ build.sbt | 7 ++- conf/application.conf | 31 ++++++++++++ conf/logback.xml | 2 + 9 files changed, 205 insertions(+), 52 deletions(-) create mode 100644 app/Module.scala create mode 100644 app/services/session/ClusterSystem.scala create mode 100644 app/services/session/ReplicatedCache.scala diff --git a/app/Module.scala b/app/Module.scala new file mode 100644 index 000000000..0d0c36fb2 --- /dev/null +++ b/app/Module.scala @@ -0,0 +1,10 @@ +import com.google.inject.AbstractModule +import play.api.libs.concurrent.AkkaGuiceSupport +import services.session.{ClusterSystem, ReplicatedCache} + +class Module extends AbstractModule with AkkaGuiceSupport { + def configure(): Unit = { + bind(classOf[ClusterSystem]).asEagerSingleton() + bindActor[ReplicatedCache]("replicatedCache") + } +} diff --git a/app/controllers/LoginController.scala b/app/controllers/LoginController.scala index 750152b9f..28174f352 100644 --- a/app/controllers/LoginController.scala +++ b/app/controllers/LoginController.scala @@ -5,22 +5,28 @@ import javax.inject.{Inject, Singleton} import play.api.data.Form import play.api.mvc._ +import scala.concurrent.{ExecutionContext, Future} + @Singleton class LoginController @Inject()(action: UserInfoAction, sessionGenerator: SessionGenerator, - cc: ControllerComponents) extends AbstractController(cc) { + cc: ControllerComponents)(implicit ec: ExecutionContext) + extends AbstractController(cc) { - def login = action { implicit request: UserRequest[AnyContent] => + def login = action.async { implicit request: UserRequest[AnyContent] => val successFunc = { userInfo: UserInfo => - val (sessionId, encryptedCookie) = sessionGenerator.createSession(userInfo) - val session = request.session + (SESSION_ID -> sessionId) - Redirect(routes.HomeController.index()) - .withSession(session) - .withCookies(encryptedCookie) + 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] => - BadRequest(views.html.index(badForm)).flashing(FLASH_ERROR -> "Could not login!") + Future.successful { + BadRequest(views.html.index(badForm)).flashing(FLASH_ERROR -> "Could not login!") + } } form.bindFromRequest().fold(errorFunc, successFunc) diff --git a/app/controllers/package.scala b/app/controllers/package.scala index a858f2736..031317d89 100644 --- a/app/controllers/package.scala +++ b/app/controllers/package.scala @@ -23,6 +23,8 @@ package object controllers { val FLASH_ERROR = "error" + val USER_INFO_COOKIE_NAME = "userInfo" + case class UserInfo(username: String) object UserInfo { @@ -37,7 +39,7 @@ package object controllers { ) def discardingSession(result: Result): Result = { - result.withNewSession.discardingCookies(DiscardingCookie("userInfo")) + result.withNewSession.discardingCookies(DiscardingCookie(USER_INFO_COOKIE_NAME)) } /** @@ -48,8 +50,8 @@ package object controllers { class UserInfoAction @Inject()(sessionService: SessionService, factory: UserInfoCookieBakerFactory, playBodyParsers: PlayBodyParsers, - messagesApi: MessagesApi, - ec: ExecutionContext) + messagesApi: MessagesApi + )(implicit val executionContext: ExecutionContext) extends ActionBuilder[UserRequest, AnyContent] { private val clock = Clock.systemUTC() @@ -57,29 +59,24 @@ package object controllers { override def parser: BodyParser[AnyContent] = playBodyParsers.anyContent override def invokeBlock[A](request: Request[A], block: (UserRequest[A]) => Future[Result]): Future[Result] = { - block(userRequestFromRequest(request)) - } - - private def userRequestFromRequest[A](request: Request[A]) = { - new UserRequest[A](request, userInfoFromRequest(request), messagesApi) + userRequestFromRequest(request).flatMap(block) } - private def userInfoFromRequest(request: RequestHeader): Option[UserInfo] = { - val maybeCookieBaker = for { - sessionId <- request.session.get(SESSION_ID) - secretKey <- sessionService.lookup(sessionId) - } yield factory.createCookieBaker(secretKey) - - maybeCookieBaker.flatMap { cookieBaker => - cookieBaker.decodeFromCookie(request.cookies.get(cookieBaker.COOKIE_NAME)) + private def userRequestFromRequest[A](request: Request[A]): Future[UserRequest[A]] = { + userInfoFromRequest(request).map { maybeUserInfo => + new UserRequest[A](request, maybeUserInfo, messagesApi) } } - override protected def executionContext: ExecutionContext = ec + private def userInfoFromRequest(request: RequestHeader): Future[Option[UserInfo]] = { + val futureMaybeSessionId = request.session.get(SESSION_ID).map(sessionService.lookup).getOrElse(Future.successful(None)) + val futureMaybeCookieBaker = futureMaybeSessionId.map(_.map(factory.createCookieBaker)) + futureMaybeCookieBaker.map(_.flatMap(_.decodeFromCookie(request.cookies.get(USER_INFO_COOKIE_NAME)))) + } } // Minimum work needed to avoid using I18nController - trait FormRequestHeader extends MessagesProvider { self: RequestHeader => + trait MessagesRequestHeader extends MessagesProvider { self: RequestHeader => def messagesApi: MessagesApi lazy val messages: Messages = messagesApi.preferred(self) } @@ -87,7 +84,7 @@ package object controllers { class UserRequest[A](request: Request[A], val userInfo: Option[UserInfo], val messagesApi: MessagesApi) - extends WrappedRequest[A](request) with FormRequestHeader + extends WrappedRequest[A](request) with MessagesRequestHeader /** * Creates a cookie baker with the given secret key. @@ -100,25 +97,26 @@ package object controllers { 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 = "userInfo" + override val COOKIE_NAME: String = USER_INFO_COOKIE_NAME } } } @Singleton - class SessionGenerator @Inject()(sessionService: SessionService, - userInfoService: EncryptionService, - factory: UserInfoCookieBakerFactory) { + class SessionGenerator @Inject()( + sessionService: SessionService, + userInfoService: EncryptionService, + factory: UserInfoCookieBakerFactory + )(implicit ec: ExecutionContext) { - def createSession(userInfo: UserInfo): (String, Cookie) = { + 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 - val sessionId = sessionService.create(secretKey) - (sessionId, userInfoCookie) + sessionService.create(secretKey).map(sessionId => (sessionId, userInfoCookie)) } } diff --git a/app/services/session/ClusterSystem.scala b/app/services/session/ClusterSystem.scala new file mode 100644 index 000000000..d52b7dab8 --- /dev/null +++ b/app/services/session/ClusterSystem.scala @@ -0,0 +1,36 @@ +package services.session + +import javax.inject.Inject + +import akka.actor.ActorSystem +import com.typesafe.config.ConfigFactory +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()(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("akka.remote.netty.tcp.port=" + port). + withFallback(ConfigFactory.load()) + + // 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/app/services/session/ReplicatedCache.scala b/app/services/session/ReplicatedCache.scala new file mode 100644 index 000000000..0f36d7baa --- /dev/null +++ b/app/services/session/ReplicatedCache.scala @@ -0,0 +1,58 @@ +package services.session + +import akka.actor.Actor +import akka.actor.ActorRef +import akka.actor.Props +import akka.cluster.Cluster +import akka.cluster.ddata.DistributedData +import akka.cluster.ddata.LWWMap +import akka.cluster.ddata.LWWMapKey + +/** + * A distributed key-store map using akka distributed data. + * + * This is from one of the examples covered in the akka distributed data section. + * + * http://doc.akka.io/docs/akka/current/scala/distributed-data.html + */ +class ReplicatedCache extends Actor { + import akka.cluster.ddata.Replicator._ + import ReplicatedCache._ + + private[this] val replicator = DistributedData(context.system).replicator + private[this] implicit val cluster = Cluster(context.system) + + def dataKey(entryKey: String): LWWMapKey[Any] = + LWWMapKey("cache-" + math.abs(entryKey.hashCode) % 100) + + def receive = { + case PutInCache(key, value) => + replicator ! Update(dataKey(key), LWWMap(), WriteLocal)(_ + (key -> value)) + case Evict(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))) => + g.dataValue match { + case data: LWWMap[_] => data.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 + } + +} + +object ReplicatedCache { + def props: Props = Props[ReplicatedCache] + + private final case class Request(key: String, replyTo: ActorRef) + + final case class PutInCache(key: String, value: Any) + final case class GetFromCache(key: String) + final case class Cached(key: String, value: Option[Any]) + final case class Evict(key: String) +} diff --git a/app/services/session/SessionService.scala b/app/services/session/SessionService.scala index d3474b993..74ed87909 100644 --- a/app/services/session/SessionService.scala +++ b/app/services/session/SessionService.scala @@ -1,34 +1,43 @@ package services.session -import javax.inject.{Inject, Singleton} +import javax.inject.{Inject, Named, Singleton} -import play.api.cache.SyncCacheApi +import akka.actor.ActorRef +import akka.pattern.ask +import services.session.ReplicatedCache._ + +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, Future} /** - * A session service that ties session id to secret key. This would probably be a - * key value store like Redis or Cassandra in a production system. - * - * @param cache + * A session service that ties session id to secret key using akka CRDTs */ @Singleton -class SessionService @Inject()(cache: SyncCacheApi) { +class SessionService @Inject()(@Named("replicatedCache") cacheActor: ActorRef)(implicit ec: ExecutionContext) { + + implicit def akkaTimeout = akka.util.Timeout(300 seconds) - def create(secretKey: Array[Byte]): String = { + def create(secretKey: Array[Byte]): Future[String] = { val sessionId = newSessionId() - cache.set(sessionId, secretKey) - sessionId + cacheActor ! PutInCache(sessionId, secretKey) + Future.successful(sessionId) } - def lookup(sessionId: String): Option[Array[Byte]] = { - cache.get[Array[Byte]](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, sessionKey: Array[Byte]): Unit = { - cache.set(sessionId, sessionKey) + def put(sessionId: String, secretKey: Array[Byte]): Future[Unit] = { + cacheActor ! PutInCache(sessionId, secretKey) + Future.successful(()) } - def delete(sessionId: String): Unit = { - cache.remove(sessionId) + def delete(sessionId: String): Future[Unit] = { + cacheActor ? Evict(sessionId) + Future.successful(()) } private val sr = new java.security.SecureRandom() diff --git a/build.sbt b/build.sbt index 8d78e1d18..006c9c013 100644 --- a/build.sbt +++ b/build.sbt @@ -7,6 +7,9 @@ lazy val root = (project in file(".")).enablePlugins(PlayScala) scalaVersion := "2.12.1" libraryDependencies += guice -libraryDependencies += cache -libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" +libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" + +libraryDependencies += "com.typesafe.akka" %% "akka-cluster" % "2.4.17" +libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data-experimental" % "2.4.17" + libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M2" % Test diff --git a/conf/application.conf b/conf/application.conf index eb761a368..473265eb6 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -6,3 +6,34 @@ # This must be changed for production, but we don't recommend you change it in this file. play.http.secret.key = "changeme" +# 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" + } + + # In production, you'd want to use TLS and protobuf for the akka serialization. + remote { + log-remote-lifecycle-events = off + netty.tcp { + hostname = "127.0.0.1" + 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 { + min-nr-of-members = 2 + seed-nodes = [ + "akka.tcp://"${play.akka.actor-system}"@127.0.0.1:2551", + "akka.tcp://"${play.akka.actor-system}"@127.0.0.1:2552" + ] + } +} diff --git a/conf/logback.xml b/conf/logback.xml index 8979d5283..fcbc8944d 100644 --- a/conf/logback.xml +++ b/conf/logback.xml @@ -24,6 +24,8 @@ + + From 2bcafa7e6b43a0082166fa4f40cfcc27af3b2908 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sun, 9 Apr 2017 16:30:44 -0700 Subject: [PATCH 21/70] Update akka distributed data (#16) Update akka-distributed-data --- app/services/session/ReplicatedCache.scala | 7 +++++-- build.sbt | 4 +--- conf/application.conf | 2 ++ project/plugins.sbt | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/services/session/ReplicatedCache.scala b/app/services/session/ReplicatedCache.scala index 0f36d7baa..2d684ea00 100644 --- a/app/services/session/ReplicatedCache.scala +++ b/app/services/session/ReplicatedCache.scala @@ -14,15 +14,18 @@ import akka.cluster.ddata.LWWMapKey * This is from one of the examples covered in the akka distributed data section. * * http://doc.akka.io/docs/akka/current/scala/distributed-data.html + * + * */ class ReplicatedCache extends Actor { + // https://github.com/akka/akka-samples/blob/master/akka-sample-distributed-data-scala/src/main/scala/sample/distributeddata/ReplicatedCache.scala import akka.cluster.ddata.Replicator._ import ReplicatedCache._ private[this] val replicator = DistributedData(context.system).replicator private[this] implicit val cluster = Cluster(context.system) - def dataKey(entryKey: String): LWWMapKey[Any] = + def dataKey(entryKey: String): LWWMapKey[String, Any] = LWWMapKey("cache-" + math.abs(entryKey.hashCode) % 100) def receive = { @@ -34,7 +37,7 @@ class ReplicatedCache extends Actor { replicator ! Get(dataKey(key), ReadLocal, Some(Request(key, sender()))) case g @ GetSuccess(LWWMapKey(_), Some(Request(key, replyTo))) => g.dataValue match { - case data: LWWMap[_] => data.get(key) match { + case data: LWWMap[_, _] => data.asInstanceOf[LWWMap[String, Any]].get(key) match { case Some(value) => replyTo ! Cached(key, Some(value)) case None => replyTo ! Cached(key, None) } diff --git a/build.sbt b/build.sbt index 006c9c013..a49930cd8 100644 --- a/build.sbt +++ b/build.sbt @@ -8,8 +8,6 @@ scalaVersion := "2.12.1" libraryDependencies += guice libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" - -libraryDependencies += "com.typesafe.akka" %% "akka-cluster" % "2.4.17" -libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data-experimental" % "2.4.17" +libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data" % "2.5.0-RC2" libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M2" % Test diff --git a/conf/application.conf b/conf/application.conf index 473265eb6..c71c279e8 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -35,5 +35,7 @@ akka { "akka.tcp://"${play.akka.actor-system}"@127.0.0.1:2551", "akka.tcp://"${play.akka.actor-system}"@127.0.0.1:2552" ] + + jmx.multi-mbeans-in-same-jvm = on } } diff --git a/project/plugins.sbt b/project/plugins.sbt index 0f364291a..d990b01cf 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,3 +1,3 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-M3") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-M4") From 74e9371484d480dce3cd607bc6bebdae89a0bb82 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Sun, 9 Apr 2017 17:03:23 -0700 Subject: [PATCH 22/70] Add note about caching (#17) --- app/services/session/ReplicatedCache.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/services/session/ReplicatedCache.scala b/app/services/session/ReplicatedCache.scala index 2d684ea00..b82baf2e2 100644 --- a/app/services/session/ReplicatedCache.scala +++ b/app/services/session/ReplicatedCache.scala @@ -13,9 +13,10 @@ import akka.cluster.ddata.LWWMapKey * * This is from one of the examples covered in the akka distributed data section. * - * http://doc.akka.io/docs/akka/current/scala/distributed-data.html - * + * Note that this does not do time to live, so if a user does not explicitly log out + * and just closes the browser window, then the session entry will stay in cache forever. * + * http://doc.akka.io/docs/akka/current/scala/distributed-data.html */ class ReplicatedCache extends Actor { // https://github.com/akka/akka-samples/blob/master/akka-sample-distributed-data-scala/src/main/scala/sample/distributeddata/ReplicatedCache.scala From cba429f660b592fffb8ca8b839a047d23a79b45e Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Mon, 10 Apr 2017 12:02:23 -0700 Subject: [PATCH 23/70] Add session expiration to the session cache. (#18) * Add session expiration * Clean out cookies --- app/Module.scala | 4 +- app/controllers/package.scala | 40 +++--- app/services/session/ReplicatedCache.scala | 62 ---------- app/services/session/SessionCache.scala | 136 +++++++++++++++++++++ app/services/session/SessionService.scala | 4 +- app/views/index.scala.html | 6 + conf/application.conf | 3 + 7 files changed, 174 insertions(+), 81 deletions(-) delete mode 100644 app/services/session/ReplicatedCache.scala create mode 100644 app/services/session/SessionCache.scala diff --git a/app/Module.scala b/app/Module.scala index 0d0c36fb2..428b8a650 100644 --- a/app/Module.scala +++ b/app/Module.scala @@ -1,10 +1,10 @@ import com.google.inject.AbstractModule import play.api.libs.concurrent.AkkaGuiceSupport -import services.session.{ClusterSystem, ReplicatedCache} +import services.session.{ClusterSystem, SessionCache} class Module extends AbstractModule with AkkaGuiceSupport { def configure(): Unit = { bind(classOf[ClusterSystem]).asEagerSingleton() - bindActor[ReplicatedCache]("replicatedCache") + bindActor[SessionCache]("replicatedCache") } } diff --git a/app/controllers/package.scala b/app/controllers/package.scala index 031317d89..3468564bc 100644 --- a/app/controllers/package.scala +++ b/app/controllers/package.scala @@ -1,4 +1,3 @@ -import java.time.Clock import javax.inject.{Inject, Singleton} import play.api.http.SecretConfiguration @@ -52,26 +51,37 @@ package object controllers { playBodyParsers: PlayBodyParsers, messagesApi: MessagesApi )(implicit val executionContext: ExecutionContext) - extends ActionBuilder[UserRequest, AnyContent] { - - private val clock = Clock.systemUTC() + 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] = { - userRequestFromRequest(request).flatMap(block) - } - - private def userRequestFromRequest[A](request: Request[A]): Future[UserRequest[A]] = { - userInfoFromRequest(request).map { maybeUserInfo => - new UserRequest[A](request, maybeUserInfo, messagesApi) + // 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!") + } + } } - } - private def userInfoFromRequest(request: RequestHeader): Future[Option[UserInfo]] = { - val futureMaybeSessionId = request.session.get(SESSION_ID).map(sessionService.lookup).getOrElse(Future.successful(None)) - val futureMaybeCookieBaker = futureMaybeSessionId.map(_.map(factory.createCookieBaker)) - futureMaybeCookieBaker.map(_.flatMap(_.decodeFromCookie(request.cookies.get(USER_INFO_COOKIE_NAME)))) + maybeFutureResult.getOrElse { + block(new UserRequest[A](request, None, messagesApi)) + } } } diff --git a/app/services/session/ReplicatedCache.scala b/app/services/session/ReplicatedCache.scala deleted file mode 100644 index b82baf2e2..000000000 --- a/app/services/session/ReplicatedCache.scala +++ /dev/null @@ -1,62 +0,0 @@ -package services.session - -import akka.actor.Actor -import akka.actor.ActorRef -import akka.actor.Props -import akka.cluster.Cluster -import akka.cluster.ddata.DistributedData -import akka.cluster.ddata.LWWMap -import akka.cluster.ddata.LWWMapKey - -/** - * A distributed key-store map using akka distributed data. - * - * This is from one of the examples covered in the akka distributed data section. - * - * Note that this does not do time to live, so if a user does not explicitly log out - * and just closes the browser window, then the session entry will stay in cache forever. - * - * http://doc.akka.io/docs/akka/current/scala/distributed-data.html - */ -class ReplicatedCache extends Actor { - // https://github.com/akka/akka-samples/blob/master/akka-sample-distributed-data-scala/src/main/scala/sample/distributeddata/ReplicatedCache.scala - import akka.cluster.ddata.Replicator._ - import ReplicatedCache._ - - private[this] val replicator = DistributedData(context.system).replicator - private[this] implicit val cluster = Cluster(context.system) - - def dataKey(entryKey: String): LWWMapKey[String, Any] = - LWWMapKey("cache-" + math.abs(entryKey.hashCode) % 100) - - def receive = { - case PutInCache(key, value) => - replicator ! Update(dataKey(key), LWWMap(), WriteLocal)(_ + (key -> value)) - case Evict(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))) => - g.dataValue match { - case data: LWWMap[_, _] => data.asInstanceOf[LWWMap[String, Any]].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 - } - -} - -object ReplicatedCache { - def props: Props = Props[ReplicatedCache] - - private final case class Request(key: String, replyTo: ActorRef) - - final case class PutInCache(key: String, value: Any) - final case class GetFromCache(key: String) - final case class Cached(key: String, value: Option[Any]) - final case class Evict(key: String) -} diff --git a/app/services/session/SessionCache.scala b/app/services/session/SessionCache.scala new file mode 100644 index 000000000..51a62b5c2 --- /dev/null +++ b/app/services/session/SessionCache.scala @@ -0,0 +1,136 @@ +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. + * + * 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, Any]].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, Any] = 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: Any) + + final case class GetFromCache(key: String) + + final case class Cached(key: String, value: Option[Any]) + + 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/app/services/session/SessionService.scala b/app/services/session/SessionService.scala index 74ed87909..3d1de2b5c 100644 --- a/app/services/session/SessionService.scala +++ b/app/services/session/SessionService.scala @@ -4,7 +4,7 @@ import javax.inject.{Inject, Named, Singleton} import akka.actor.ActorRef import akka.pattern.ask -import services.session.ReplicatedCache._ +import services.session.SessionCache._ import scala.concurrent.duration._ import scala.concurrent.{ExecutionContext, Future} @@ -15,7 +15,7 @@ import scala.concurrent.{ExecutionContext, Future} @Singleton class SessionService @Inject()(@Named("replicatedCache") cacheActor: ActorRef)(implicit ec: ExecutionContext) { - implicit def akkaTimeout = akka.util.Timeout(300 seconds) + implicit def akkaTimeout = akka.util.Timeout(300.milliseconds) def create(secretKey: Array[Byte]): Future[String] = { val sessionId = newSessionId() diff --git a/app/views/index.scala.html b/app/views/index.scala.html index 6f1929744..1f5257f40 100644 --- a/app/views/index.scala.html +++ b/app/views/index.scala.html @@ -2,6 +2,12 @@ @main("play-scala-secure-session-example") { + @request.flash.data.map{ case (k, v) => +

+ @k: @v +

+ } +

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

diff --git a/conf/application.conf b/conf/application.conf index c71c279e8..81d1a70bc 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -6,6 +6,9 @@ # This must be changed for production, but we don't recommend you change it in this file. play.http.secret.key = "changeme" +# 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 { From c815c64461f369a66fcf3eb00a7c4dffc51979cb Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Mon, 10 Apr 2017 12:35:13 -0700 Subject: [PATCH 24/70] Fix a minor issue with config parsing (#19) --- app/services/session/ClusterSystem.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/services/session/ClusterSystem.scala b/app/services/session/ClusterSystem.scala index d52b7dab8..772a34175 100644 --- a/app/services/session/ClusterSystem.scala +++ b/app/services/session/ClusterSystem.scala @@ -4,6 +4,7 @@ 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 @@ -15,14 +16,14 @@ import scala.concurrent.Future * Normally you'd run several play instances, and the port would be the * same while you had several different ip addresses. */ -class ClusterSystem @Inject()(applicationLifecycle: ApplicationLifecycle) { +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("akka.remote.netty.tcp.port=" + port). - withFallback(ConfigFactory.load()) + 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 From 827d1dd14219813398714099a6d0f88b41b7fabb Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Mon, 10 Apr 2017 19:31:10 -0300 Subject: [PATCH 25/70] Update to play 2.6.0-M4 (#20) --- build.sbt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index a49930cd8..64f1e926a 100644 --- a/build.sbt +++ b/build.sbt @@ -10,4 +10,5 @@ libraryDependencies += guice libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data" % "2.5.0-RC2" -libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M2" % Test +libraryDependencies += "com.typesafe.play" %% "play-ahc-ws" % "2.6.0-M4" % Test +libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M2" % Test \ No newline at end of file From 2557faaa3d288335f70a17e7789007b699f88c14 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Tue, 11 Apr 2017 07:51:20 -0700 Subject: [PATCH 26/70] Updated with template-control on 2017-04-11T04:24:17.195Z (#21) /LICENSE: wrote /LICENSE **/build.properties: sbt.version=0.13.15 --- build.sbt | 2 +- project/build.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index 64f1e926a..03549ba3c 100644 --- a/build.sbt +++ b/build.sbt @@ -11,4 +11,4 @@ libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data" % "2.5.0-RC2" libraryDependencies += "com.typesafe.play" %% "play-ahc-ws" % "2.6.0-M4" % Test -libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M2" % Test \ No newline at end of file +libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M2" % Test diff --git a/project/build.properties b/project/build.properties index 27e88aa11..64317fdae 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=0.13.13 +sbt.version=0.13.15 From 6b9578b11a168e435065ad32619e56cbfd611876 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Thu, 13 Apr 2017 10:14:05 -0700 Subject: [PATCH 27/70] Use artery serialization (#22) * Use artery serialization * Better documentation * Some formatting --- README.md | 56 ++++++++++++++++++- app/Module.scala | 10 ++-- app/controllers/HomeController.scala | 6 +- app/controllers/LoginController.scala | 29 +++++----- app/controllers/LogoutController.scala | 9 +-- app/controllers/package.scala | 47 +++++++++------- .../encryption/EncryptedCookieBaker.scala | 11 ++-- .../encryption/EncryptionService.scala | 16 +++--- app/services/session/ClusterSystem.scala | 7 ++- app/services/session/SessionCache.scala | 14 ++--- app/services/session/SessionService.scala | 6 +- build.sbt | 2 +- conf/application.conf | 33 ++++++++--- conf/logback.xml | 3 +- project/plugins.sbt | 1 - 15 files changed, 164 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index b12161177..0ef3e03dc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # play-scala-secure-session-example -This is an example application that shows how to use symmetric encryption with [Kalium](https://github.com/abstractj/kalium/), a Java wrapper around libsodium, to do simple secure session management in Play, using the Scala API and session cookies. +This is an example application that shows how to do simple secure session management in Play, using the Scala API and session cookies. ## Overview @@ -10,9 +10,35 @@ The only server side state is a mapping of session ids to secret keys. When the ## Prerequisites -You must install libsodium before using this application. If you have homebrew, you can use `brew install libsodium`. +As with all Play projects, you must have JDK 1.8 and [sbt](http://www.scala-sbt.org/) installed. -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: + +``` +brew install libsodium +``` + +If you are on Ubuntu >= 15.04 or Debian >= 8, you can install with apt-get: + +``` +apt-get install libsodium-dev +``` + +On Fedora: + +``` +dnf install libsodium-devel +``` + +On CentOS: + +``` +yum install libsodium-devel +``` + +For Windows, you can download pre-built libraries using the [install page](https://download.libsodium.org/doc/installation/). ## Running @@ -23,3 +49,27 @@ sbt run ``` Then go to http://localhost:9000 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 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. + +Play uses the `services.session.SessionService` to provide a `Future` based API that 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. + +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. + +## ClusterSystem + +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. \ No newline at end of file diff --git a/app/Module.scala b/app/Module.scala index 428b8a650..df4260ff4 100644 --- a/app/Module.scala +++ b/app/Module.scala @@ -1,10 +1,10 @@ import com.google.inject.AbstractModule import play.api.libs.concurrent.AkkaGuiceSupport -import services.session.{ClusterSystem, SessionCache} +import services.session.{ ClusterSystem, SessionCache } class Module extends AbstractModule with AkkaGuiceSupport { - def configure(): Unit = { - bind(classOf[ClusterSystem]).asEagerSingleton() - bindActor[SessionCache]("replicatedCache") - } + def configure(): Unit = { + bind(classOf[ClusterSystem]).asEagerSingleton() + bindActor[SessionCache]("replicatedCache") + } } diff --git a/app/controllers/HomeController.scala b/app/controllers/HomeController.scala index 6158d489f..371e6bf89 100644 --- a/app/controllers/HomeController.scala +++ b/app/controllers/HomeController.scala @@ -5,8 +5,10 @@ import javax.inject._ import play.api.mvc._ @Singleton -class HomeController @Inject()(userAction: UserInfoAction, - cc: ControllerComponents) extends AbstractController(cc) { +class HomeController @Inject() ( + userAction: UserInfoAction, + cc: ControllerComponents +) extends AbstractController(cc) { def index = userAction { implicit request: UserRequest[_] => Ok(views.html.index(form)) diff --git a/app/controllers/LoginController.scala b/app/controllers/LoginController.scala index 28174f352..2ba2ff5fa 100644 --- a/app/controllers/LoginController.scala +++ b/app/controllers/LoginController.scala @@ -1,25 +1,28 @@ package controllers -import javax.inject.{Inject, Singleton} +import javax.inject.{ Inject, Singleton } import play.api.data.Form import play.api.mvc._ -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.{ ExecutionContext, Future } @Singleton -class LoginController @Inject()(action: UserInfoAction, - sessionGenerator: SessionGenerator, - cc: ControllerComponents)(implicit ec: ExecutionContext) - extends AbstractController(cc) { - - def login = action.async { implicit request: UserRequest[AnyContent] => +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) + sessionGenerator.createSession(userInfo).map { + case (sessionId, encryptedCookie) => + val session = request.session + (SESSION_ID -> sessionId) + Redirect(routes.HomeController.index()) + .withSession(session) + .withCookies(encryptedCookie) } } diff --git a/app/controllers/LogoutController.scala b/app/controllers/LogoutController.scala index ebf2363d7..eff6c45cc 100644 --- a/app/controllers/LogoutController.scala +++ b/app/controllers/LogoutController.scala @@ -1,14 +1,15 @@ package controllers -import javax.inject.{Inject, Singleton} +import javax.inject.{ Inject, Singleton } import play.api.mvc._ import services.session.SessionService @Singleton -class LogoutController @Inject()(sessionService: SessionService, - cc: ControllerComponents) extends AbstractController(cc) { - +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 diff --git a/app/controllers/package.scala b/app/controllers/package.scala index 3468564bc..bc84fd9ca 100644 --- a/app/controllers/package.scala +++ b/app/controllers/package.scala @@ -1,14 +1,14 @@ -import javax.inject.{Inject, Singleton} +import javax.inject.{ Inject, Singleton } import play.api.http.SecretConfiguration -import play.api.i18n.{Messages, MessagesApi, MessagesProvider} -import play.api.libs.json.{Format, Json} +import play.api.i18n.{ Messages, MessagesApi, MessagesProvider } +import play.api.libs.json.{ Format, Json } import play.api.mvc._ -import services.encryption.{EncryptedCookieBaker, EncryptionService} +import services.encryption.{ EncryptedCookieBaker, EncryptionService } import services.session.SessionService import scala.concurrent.duration._ -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.{ ExecutionContext, Future } /** * Methods and objects common to all controllers @@ -46,12 +46,13 @@ package object controllers { * 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 { + 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 @@ -91,17 +92,21 @@ package object controllers { lazy val messages: Messages = messagesApi.preferred(self) } - class UserRequest[A](request: Request[A], - val userInfo: Option[UserInfo], - val messagesApi: MessagesApi) - extends WrappedRequest[A](request) with MessagesRequestHeader + class UserRequest[A]( + request: Request[A], + val userInfo: Option[UserInfo], + val messagesApi: MessagesApi + ) + extends WrappedRequest[A](request) with MessagesRequestHeader /** * Creates a cookie baker with the given secret key. */ @Singleton - class UserInfoCookieBakerFactory @Inject()(encryptionService: EncryptionService, - secretConfiguration: SecretConfiguration) { + class UserInfoCookieBakerFactory @Inject() ( + encryptionService: EncryptionService, + secretConfiguration: SecretConfiguration + ) { def createCookieBaker(secretKey: Array[Byte]): EncryptedCookieBaker[UserInfo] = { new EncryptedCookieBaker[UserInfo](secretKey, encryptionService, secretConfiguration) { @@ -113,10 +118,10 @@ package object controllers { } @Singleton - class SessionGenerator @Inject()( - sessionService: SessionService, - userInfoService: EncryptionService, - factory: UserInfoCookieBakerFactory + class SessionGenerator @Inject() ( + sessionService: SessionService, + userInfoService: EncryptionService, + factory: UserInfoCookieBakerFactory )(implicit ec: ExecutionContext) { def createSession(userInfo: UserInfo): Future[(String, Cookie)] = { diff --git a/app/services/encryption/EncryptedCookieBaker.scala b/app/services/encryption/EncryptedCookieBaker.scala index 34e296836..66c6d4009 100644 --- a/app/services/encryption/EncryptedCookieBaker.scala +++ b/app/services/encryption/EncryptedCookieBaker.scala @@ -1,6 +1,6 @@ package services.encryption -import play.api.http.{JWTConfiguration, SecretConfiguration} +import play.api.http.{ JWTConfiguration, SecretConfiguration } import play.api.libs.json.Format import play.api.mvc._ @@ -9,10 +9,11 @@ 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 { +abstract class EncryptedCookieBaker[A: Format]( + secretKey: Array[Byte], + encryptionService: EncryptionService, + val secretConfiguration: SecretConfiguration +) extends CookieBaker[Option[A]] with JWTCookieDataCodec { def expirationDate: FiniteDuration diff --git a/app/services/encryption/EncryptionService.scala b/app/services/encryption/EncryptionService.scala index fe6190154..1e137dcf7 100644 --- a/app/services/encryption/EncryptionService.scala +++ b/app/services/encryption/EncryptionService.scala @@ -2,17 +2,16 @@ 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} +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) { +class EncryptionService @Inject() (configuration: Configuration) { private val random = new SecureRandom() @@ -31,7 +30,7 @@ class EncryptionService @Inject()(configuration: Configuration) { val nonce = Nonce.createNonce() val json = Json.toJson(userInfo) val stringData = Json.stringify(json) - logger.info(s"encrypt: stringData = $stringData") + logger.info(s"encrypt: userInfo = $userInfo, stringData = $stringData") val rawData = stringData.getBytes(StandardCharsets.UTF_8) val cipherText = box(secretKey).encrypt(nonce.raw, rawData) @@ -49,9 +48,10 @@ class EncryptionService @Inject()(configuration: Configuration) { val rawData = box(secretKey).decrypt(nonce.raw, cipherText) val stringData = new String(rawData, StandardCharsets.UTF_8) val json = Json.parse(stringData) - val result: JsResult[A] = Json.fromJson[A](json) + val result = Json.fromJson[A](json).asOpt logger.info(s"decrypt: json = $json, result = $result") - result.asOpt + result + } private def encoder = org.abstractj.kalium.encoders.Encoder.HEX diff --git a/app/services/session/ClusterSystem.scala b/app/services/session/ClusterSystem.scala index 772a34175..d9b4a3a2a 100644 --- a/app/services/session/ClusterSystem.scala +++ b/app/services/session/ClusterSystem.scala @@ -16,14 +16,15 @@ import scala.concurrent.Future * 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) { +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("akka.remote.netty.tcp.port=" + port). - withFallback(configuration.underlying) + 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 diff --git a/app/services/session/SessionCache.scala b/app/services/session/SessionCache.scala index 51a62b5c2..bb09f0aa1 100644 --- a/app/services/session/SessionCache.scala +++ b/app/services/session/SessionCache.scala @@ -2,7 +2,7 @@ package services.session import javax.inject.Inject -import akka.actor.{Actor, ActorLogging, ActorRef, Cancellable, Props} +import akka.actor.{ Actor, ActorLogging, ActorRef, Cancellable, Props } import akka.event.LoggingReceive import scala.concurrent.duration._ @@ -23,7 +23,7 @@ class SessionCache extends Actor with ActorLogging { import SessionCache._ import SessionExpiration._ import akka.cluster.Cluster - import akka.cluster.ddata.{DistributedData, LWWMap, LWWMapKey} + import akka.cluster.ddata.{ DistributedData, LWWMap, LWWMapKey } import akka.cluster.ddata.Replicator._ private val expirationTime: FiniteDuration = { @@ -47,10 +47,10 @@ class SessionCache extends Actor with ActorLogging { case GetFromCache(key) => replicator ! Get(dataKey(key), ReadLocal, Some(Request(key, sender()))) - case g@GetSuccess(LWWMapKey(_), Some(Request(key, replyTo))) => + case g @ GetSuccess(LWWMapKey(_), Some(Request(key, replyTo))) => refreshSessionExpiration(key) g.dataValue match { - case data: LWWMap[_, _] => data.asInstanceOf[LWWMap[String, Any]].get(key) 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) } @@ -62,7 +62,7 @@ class SessionCache extends Actor with ActorLogging { case _: UpdateResponse[_] => // ok } - private def dataKey(key: String): LWWMapKey[String, Any] = LWWMapKey(key) + private def dataKey(key: String): LWWMapKey[String, Array[Byte]] = LWWMapKey(key) private def refreshSessionExpiration(key: String) = { context.child(key) match { @@ -85,11 +85,11 @@ class SessionCache extends Actor with ActorLogging { object SessionCache { def props: Props = Props[SessionCache] - final case class PutInCache(key: String, value: Any) + final case class PutInCache(key: String, value: Array[Byte]) final case class GetFromCache(key: String) - final case class Cached(key: String, value: Option[Any]) + final case class Cached(key: String, value: Option[Array[Byte]]) final case class Evict(key: String) diff --git a/app/services/session/SessionService.scala b/app/services/session/SessionService.scala index 3d1de2b5c..7b9e87d13 100644 --- a/app/services/session/SessionService.scala +++ b/app/services/session/SessionService.scala @@ -1,19 +1,19 @@ package services.session -import javax.inject.{Inject, Named, Singleton} +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} +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) { +class SessionService @Inject() (@Named("replicatedCache") cacheActor: ActorRef)(implicit ec: ExecutionContext) { implicit def akkaTimeout = akka.util.Timeout(300.milliseconds) diff --git a/build.sbt b/build.sbt index 03549ba3c..bdfb7e38d 100644 --- a/build.sbt +++ b/build.sbt @@ -8,7 +8,7 @@ scalaVersion := "2.12.1" libraryDependencies += guice libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" -libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data" % "2.5.0-RC2" +libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data" % "2.5.0" libraryDependencies += "com.typesafe.play" %% "play-ahc-ws" % "2.6.0-M4" % Test libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M2" % Test diff --git a/conf/application.conf b/conf/application.conf index 81d1a70bc..393e3a4b7 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -18,14 +18,27 @@ akka { 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 + } } - # In production, you'd want to use TLS and protobuf for the akka serialization. remote { - log-remote-lifecycle-events = off - netty.tcp { - hostname = "127.0.0.1" - port = 0 + log-remote-lifecycle-events = on + + artery { + enabled = on + canonical.hostname = "127.0.0.1" + canonical.port = 0 } } @@ -33,12 +46,14 @@ akka { # 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.tcp://"${play.akka.actor-system}"@127.0.0.1:2551", - "akka.tcp://"${play.akka.actor-system}"@127.0.0.1:2552" + "akka://"${play.akka.actor-system}"@127.0.0.1:2551", + "akka://"${play.akka.actor-system}"@127.0.0.1:2552" ] - - jmx.multi-mbeans-in-same-jvm = on } } + diff --git a/conf/logback.xml b/conf/logback.xml index fcbc8944d..5611339c0 100644 --- a/conf/logback.xml +++ b/conf/logback.xml @@ -24,7 +24,8 @@ - + + diff --git a/project/plugins.sbt b/project/plugins.sbt index d990b01cf..871e5bd11 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,3 +1,2 @@ // The Play plugin addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-M4") - From c5f5ebe6c9cfd068a76533310eca215dd3e87faa Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Thu, 13 Apr 2017 10:15:16 -0700 Subject: [PATCH 28/70] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0ef3e03dc..7ff3b0c61 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ A abstract [cookie baker](https://www.playframework.com/documentation/latest/api 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 the underlying logic. +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 @@ -72,4 +72,4 @@ Note that the map is not persisted in this example, so if all the Play instances ## ClusterSystem -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. \ No newline at end of file +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. From eb4abfcee162ffb45babb161b8618fa1231c9897 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Thu, 13 Apr 2017 10:15:48 -0700 Subject: [PATCH 29/70] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7ff3b0c61..bb65881e0 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Then finally, a `UserInfoAction`, an action builder, handles the work of reading 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. -Play uses the `services.session.SessionService` to provide a `Future` based API that 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. +Play uses `services.session.SessionService` to provide a `Future` based API that 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. From 75b7fb0da4fe0b10217674883b68d6ceab30694c Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Tue, 18 Apr 2017 12:45:52 -0700 Subject: [PATCH 30/70] Updated with template-control on 2017-04-18T19:22:38.608Z (#24) /LICENSE: wrote /LICENSE **/build.sbt: scalaVersion := "2.12.2" --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index bdfb7e38d..b3a970b8c 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,7 @@ version := "1.0-SNAPSHOT" lazy val root = (project in file(".")).enablePlugins(PlayScala) -scalaVersion := "2.12.1" +scalaVersion := "2.12.2" libraryDependencies += guice libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" From 340c14f6892288759875b8c5910a5103fc76a75a Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Tue, 18 Apr 2017 13:45:40 -0700 Subject: [PATCH 31/70] Updated with template-control on 2017-04-18T20:11:55.469Z (#25) /LICENSE: wrote /LICENSE **/build.sbt: libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M3" % Test --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index b3a970b8c..b40ee21ed 100644 --- a/build.sbt +++ b/build.sbt @@ -11,4 +11,4 @@ libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data" % "2.5.0" libraryDependencies += "com.typesafe.play" %% "play-ahc-ws" % "2.6.0-M4" % Test -libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M2" % Test +libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M3" % Test From ff543cc60715c5603e59f5493b26dca0e657f70c Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Thu, 27 Apr 2017 19:00:52 -0700 Subject: [PATCH 32/70] Updated with template-control on 2017-04-28T01:46:59.627Z (#29) /LICENSE: wrote /LICENSE **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-M5") --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 871e5bd11..e2831ab29 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-M4") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-M5") From f6011e5ed075395182711c71de21a3d895a2dc57 Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Fri, 28 Apr 2017 11:51:34 -0700 Subject: [PATCH 33/70] Remove message header (#32) --- app/controllers/package.scala | 11 ++++------- app/views/index.scala.html | 2 +- build.sbt | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/app/controllers/package.scala b/app/controllers/package.scala index bc84fd9ca..4e6824ae5 100644 --- a/app/controllers/package.scala +++ b/app/controllers/package.scala @@ -1,7 +1,7 @@ import javax.inject.{ Inject, Singleton } import play.api.http.SecretConfiguration -import play.api.i18n.{ Messages, MessagesApi, MessagesProvider } +import play.api.i18n.MessagesApi import play.api.libs.json.{ Format, Json } import play.api.mvc._ import services.encryption.{ EncryptedCookieBaker, EncryptionService } @@ -86,18 +86,15 @@ package object controllers { } } - // Minimum work needed to avoid using I18nController - trait MessagesRequestHeader extends MessagesProvider { self: RequestHeader => - def messagesApi: MessagesApi - lazy val messages: Messages = messagesApi.preferred(self) + 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 MessagesRequestHeader + ) extends WrappedRequest[A](request) with UserRequestHeader /** * Creates a cookie baker with the given secret key. diff --git a/app/views/index.scala.html b/app/views/index.scala.html index 1f5257f40..fa7770948 100644 --- a/app/views/index.scala.html +++ b/app/views/index.scala.html @@ -1,4 +1,4 @@ -@(form: Form[UserInfo])(implicit request: UserRequest[_]) +@(form: Form[UserInfo])(implicit request: UserRequestHeader) @main("play-scala-secure-session-example") { diff --git a/build.sbt b/build.sbt index b40ee21ed..910fb62f3 100644 --- a/build.sbt +++ b/build.sbt @@ -7,7 +7,7 @@ lazy val root = (project in file(".")).enablePlugins(PlayScala) scalaVersion := "2.12.2" libraryDependencies += guice -libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.4.0" +libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.6.0" libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data" % "2.5.0" libraryDependencies += "com.typesafe.play" %% "play-ahc-ws" % "2.6.0-M4" % Test From 5305284c5a1ea47306dec4330285fc2ed51880e8 Mon Sep 17 00:00:00 2001 From: Greg Methvin Date: Sun, 4 Jun 2017 00:09:50 +0200 Subject: [PATCH 34/70] Updated with template-control on 2017-06-03T16:01:09.472Z (#37) **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-RC2") --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index e2831ab29..4955cd70e 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-M5") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-RC2") From 29c870c2c5bac49245f62a1e5dc63d49c96ade27 Mon Sep 17 00:00:00 2001 From: Greg Methvin Date: Thu, 22 Jun 2017 19:41:11 -0700 Subject: [PATCH 35/70] Updated with template-control on 2017-06-23T02:18:35.853Z (#38) **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0") --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 4955cd70e..90330f7ef 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0-RC2") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0") From 42ee3c075ddd43c098c0daa071c528645449981f Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Fri, 30 Jun 2017 11:43:24 -0700 Subject: [PATCH 36/70] Calm down akka logging (#39) --- conf/application.conf | 9 +-------- conf/logback.xml | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/conf/application.conf b/conf/application.conf index 393e3a4b7..3ad317a1a 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -1,11 +1,4 @@ -## Secret key -# http://www.playframework.com/documentation/latest/ApplicationSecret -# ~~~~~ -# The secret key is used to sign Play's session cookie. -# This must be changed for production, but we don't recommend you change it in this file. -play.http.secret.key = "changeme" - # The SessionCache expiration time if not touched session.expirationTime = 5 minutes @@ -33,7 +26,7 @@ akka { } remote { - log-remote-lifecycle-events = on + log-remote-lifecycle-events = off artery { enabled = on diff --git a/conf/logback.xml b/conf/logback.xml index 5611339c0..8d09b07aa 100644 --- a/conf/logback.xml +++ b/conf/logback.xml @@ -25,7 +25,7 @@ - + From 240b7e6ffb4d09aec6a6626dbada79f1639121ca Mon Sep 17 00:00:00 2001 From: Greg Methvin Date: Fri, 7 Jul 2017 22:16:41 -0700 Subject: [PATCH 37/70] Updated with template-control on 2017-07-07T00:45:45.173Z (#40) **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.1") --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 90330f7ef..ac1a34d4a 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.0") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.1") From 9d0cb2c9017204887b6fbc81561c501a276bef42 Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Thu, 20 Jul 2017 00:06:49 -0300 Subject: [PATCH 38/70] Updated with template-control on 2017-07-20T01:10:12.459Z (#43) **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.2") --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index ac1a34d4a..2052d7ca4 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.1") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.2") From cf7f3db67f827d9806b56baeedf801d7350aa1b0 Mon Sep 17 00:00:00 2001 From: harry Date: Thu, 27 Jul 2017 05:54:35 +0900 Subject: [PATCH 39/70] Remove use of deprecated constants (#41) --- app/services/encryption/EncryptionService.scala | 4 ++-- app/services/encryption/Nonce.scala | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/services/encryption/EncryptionService.scala b/app/services/encryption/EncryptionService.scala index 1e137dcf7..56a95724a 100644 --- a/app/services/encryption/EncryptionService.scala +++ b/app/services/encryption/EncryptionService.scala @@ -20,8 +20,8 @@ class EncryptionService @Inject() (configuration: Configuration) { // 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.XSALSA20_POLY1305_SECRETBOX_KEYBYTES - val buf = new Array[Byte](XSALSA20_POLY1305_SECRETBOX_KEYBYTES) + import org.abstractj.kalium.NaCl.Sodium.CRYPTO_SECRETBOX_XSALSA20POLY1305_KEYBYTES + val buf = new Array[Byte](CRYPTO_SECRETBOX_XSALSA20POLY1305_KEYBYTES) random.nextBytes(buf) buf } diff --git a/app/services/encryption/Nonce.scala b/app/services/encryption/Nonce.scala index 4d8d7c8ff..96c161287 100644 --- a/app/services/encryption/Nonce.scala +++ b/app/services/encryption/Nonce.scala @@ -18,16 +18,16 @@ object Nonce { * Creates a random nonce value. */ def createNonce(): Nonce = { - import org.abstractj.kalium.NaCl.Sodium.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES - new Nonce(random.randomBytes(XSALSA20_POLY1305_SECRETBOX_NONCEBYTES)) + 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.XSALSA20_POLY1305_SECRETBOX_NONCEBYTES - if (data == null || data.length != XSALSA20_POLY1305_SECRETBOX_NONCEBYTES) { + 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) From 8a3d2cbc72d104eedcbcc5323bc0dea22b34dbb7 Mon Sep 17 00:00:00 2001 From: Rich Dougherty Date: Sat, 12 Aug 2017 16:27:38 +1200 Subject: [PATCH 40/70] Updated with template-control on 2017-08-12T02:43:22.442Z (#44) **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.3") --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 2052d7ca4..66fbf368a 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.2") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.3") From ef08a0ec606a2c3a4d18ab8da975307851c41896 Mon Sep 17 00:00:00 2001 From: Greg Methvin Date: Thu, 21 Sep 2017 10:22:10 -0700 Subject: [PATCH 41/70] Updated with template-control on 2017-09-14T23:29:36.707Z (#45) **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.5") --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 66fbf368a..704e7f892 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.3") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.5") From 1dabfb6518aa58045c81b18e2c7c613613b6fcef Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Thu, 21 Sep 2017 16:12:59 -0700 Subject: [PATCH 42/70] Update security with URL (#47) --- app/services/session/SessionCache.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/services/session/SessionCache.scala b/app/services/session/SessionCache.scala index bb09f0aa1..00ed2a63b 100644 --- a/app/services/session/SessionCache.scala +++ b/app/services/session/SessionCache.scala @@ -13,7 +13,8 @@ import scala.concurrent.duration._ * 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. + * 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 */ From 16d7bb72a6f1078837f77f363c6501f44d6d2e58 Mon Sep 17 00:00:00 2001 From: Greg Methvin Date: Thu, 5 Oct 2017 17:34:02 -0700 Subject: [PATCH 43/70] Updated with template-control on 2017-10-05T23:19:00.490Z (#51) **/build.properties: sbt.version=1.0.2 **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.6") --- project/build.properties | 2 +- project/plugins.sbt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/project/build.properties b/project/build.properties index 64317fdae..b7dd3cb2a 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=0.13.15 +sbt.version=1.0.2 diff --git a/project/plugins.sbt b/project/plugins.sbt index 704e7f892..cc959dd08 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.5") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.6") From 61a0b677a1c879e3b561bb06b10e2f67dbc626c6 Mon Sep 17 00:00:00 2001 From: Bora Kaplan Date: Thu, 12 Oct 2017 18:03:00 +0300 Subject: [PATCH 44/70] Fixed typo on readme.md (#50) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bb65881e0..8f8052263 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Then go to http://localhost:9000 to see the server. 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 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. From 09ada77c39aa95c85aaf31de16f43bcae7b6c33b Mon Sep 17 00:00:00 2001 From: Will Sargent Date: Thu, 12 Oct 2017 13:33:56 -0400 Subject: [PATCH 45/70] Add discussion about session-store (#48) * Add discussion about session-store * Make it clearer what concerns are --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8f8052263..859b1bd29 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,20 @@ Then finally, a `UserInfoAction`, an action builder, handles the work of reading 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. -Play uses `services.session.SessionService` to provide a `Future` based API that 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. +This example uses `services.session.SessionService` to provide a `Future` based API around a session store. -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. +### Distributed Data Session Store -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. +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. -## ClusterSystem +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. From b230d987b78ff823d6f1a9f1547f23c53dc29418 Mon Sep 17 00:00:00 2001 From: Greg Methvin Date: Wed, 1 Nov 2017 20:25:32 -0700 Subject: [PATCH 46/70] Updated with template-control on 2017-11-02T02:51:11.306Z (#53) **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.7") --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index cc959dd08..6bc77e207 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.6") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.7") From 39bc9e77b9c0cfd2de11662e9c951dd1d3da02c6 Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Mon, 18 Dec 2017 10:12:08 -0200 Subject: [PATCH 47/70] Update Play and other dependencies (#55) * Update Play and other dependencies * Update Scala versions used by travis --- .travis.yml | 4 ++-- README.md | 16 ++++++++-------- build.sbt | 8 ++++---- project/build.properties | 2 +- project/plugins.sbt | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.travis.yml b/.travis.yml index 07183c55d..5fdfa0d29 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,8 +5,8 @@ dist: trusty sudo: true group: beta scala: -- 2.11.8 -- 2.12.1 +- 2.11.12 +- 2.12.4 jdk: - oraclejdk8 before_install: diff --git a/README.md b/README.md index 859b1bd29..d243f512e 100644 --- a/README.md +++ b/README.md @@ -16,25 +16,25 @@ However, you must install libsodium before using this application, which is a no 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 ``` @@ -44,11 +44,11 @@ For Windows, you can download pre-built libraries using the [install page](https Run sbt from the command line: -``` +```bash sbt run ``` -Then go to http://localhost:9000 to see the server. +Then go to to see the server. ## Encryption @@ -74,8 +74,8 @@ The basic structure of the cache is taken from [Akka's ReplicatedCache example]( 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.** - +> 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 diff --git a/build.sbt b/build.sbt index 910fb62f3..c37240c34 100644 --- a/build.sbt +++ b/build.sbt @@ -4,11 +4,11 @@ version := "1.0-SNAPSHOT" lazy val root = (project in file(".")).enablePlugins(PlayScala) -scalaVersion := "2.12.2" +scalaVersion := "2.12.4" libraryDependencies += guice libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.6.0" -libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data" % "2.5.0" +libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data" % "2.5.8" -libraryDependencies += "com.typesafe.play" %% "play-ahc-ws" % "2.6.0-M4" % Test -libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0-M3" % Test +libraryDependencies += "com.typesafe.play" %% "play-ahc-ws" % "2.6.9" % Test +libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.1.2" % Test diff --git a/project/build.properties b/project/build.properties index b7dd3cb2a..394cb75cf 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.0.2 +sbt.version=1.0.4 diff --git a/project/plugins.sbt b/project/plugins.sbt index 6bc77e207..51cd14953 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.7") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.9") From 02be3000f814bb5ff6a56c478f946624a76b8115 Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Wed, 20 Dec 2017 18:24:44 -0200 Subject: [PATCH 48/70] Add Gradle configuration and Java 9 support (#56) --- .gitignore | 2 + .travis.yml | 31 ++-- README.md | 4 +- build.gradle | 47 +++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 172 +++++++++++++++++++++++ gradlew.bat | 84 +++++++++++ scripts/script-helper | 13 ++ scripts/test-gradle | 13 ++ scripts/test-sbt | 8 ++ 11 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 scripts/script-helper create mode 100755 scripts/test-gradle create mode 100755 scripts/test-sbt diff --git a/.gitignore b/.gitignore index eb372fc71..193873de8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ +build logs target /.idea /.idea_modules /.classpath +/.gradle /.project /.settings /RUNNING_PID diff --git a/.travis.yml b/.travis.yml index 5fdfa0d29..fc4811b19 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,30 +1,43 @@ -language: scala -# Trusty VM has 1.8u101 -# https://github.com/travis-ci/travis-ci/issues/3259#issuecomment-243534696 dist: trusty sudo: true group: beta +language: scala scala: +# When updating Scala versions, also check the excludes +# in build matrix below. - 2.11.12 - 2.12.4 jdk: - oraclejdk8 +- oraclejdk9 before_install: - sudo add-apt-repository -y ppa:ondrej/php - sudo apt-get -qq update - sudo apt-get install -y libsodium-dev +env: + matrix: + - SCRIPT=scripts/test-sbt + - SCRIPT=scripts/test-gradle +script: +- $SCRIPT cache: directories: - - $HOME/.ivy2/cache - - $HOME/.sbt/boot/ + - "$HOME/.ivy2/cache" + - "$HOME/.gradle/caches" before_cache: - # Ensure changes to the cache aren't persisted - rm -rf $HOME/.ivy2/cache/com.typesafe.play/* - rm -rf $HOME/.ivy2/cache/scala_*/sbt_*/com.typesafe.play/* - # Delete all ivydata files since ivy touches them on each build - find $HOME/.ivy2/cache -name "ivydata-*.properties" -print0 | xargs -n10 -0 rm - # Delete any SBT lock files - - find $HOME/.sbt -name "*.lock" -delete + +# Exclude some combinations from build matrix. See: +# https://docs.travis-ci.com/user/customizing-the-build/#Build-Matrix +matrix: + exclude: + - scala: 2.11.12 + jdk: oraclejdk9 + +# See https://blog.travis-ci.com/2014-03-13-slack-notifications/ +# created with travis encrypt command line tool notifications: slack: secure: D5Mj39P4P/mCRk/rSThkRRLKRCnv4qwY1ZMgYAljXAcJQDX2dFe7ZhUHeNHL02jGTL6QJqyM2lgKA1+yXjphmabqYB/fJmmaHZEx3c9XcfhRVdveIANdzVBfaHuM1YOzpx6LCO/6YlHHTxPMyBxl3q9ELguKs04nja0NTiKeSVAS7+rI8RUciqZ41zzq32PrdWmsPB76yxvftdHtCrTjIepnKfUfU/xvekWKybk0L6tj9P/rSu7Ao6pqJCRw8ct954dKHEY01C9tw3lyc7f9/kNsFVRq4A8+tuiGB/yM5pRHgmxbAzjQRyCUjccDgMf8P+NmSenb/JIMQcPt/ZR/dzABrzgQe225Y3b53IeuOyMO2j6ZtBUwJ27OQdXeAiY5VtUqcG3nmlrjQkSyjqWz7Frjj4Trgqlv1/9lSJs+ciwOiYcBjTew9zrVlfyqmza32VPKOvvStTrtYpSS99c4KkiOeEKZGX45yVMnqEdn4g6ox3bYj6oGst0t87KiX+rvnmPfsE1c+RfCI7M7wAieAeXQsX5PaLXQLKG0VedDJe8x6IP+zd9AxrzKSNlcjo6SsBHO1h9VrTNd/WKYkzkjeF0UwfUXqmZErz0y+bvfF77ATw4vmyagSy60a8tJjekJYDB/Qu5IXnlWrg96niKpf3j4Jcyn1CVvlTnTD0sbZM4= diff --git a/README.md b/README.md index d243f512e..9287342dd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 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 @@ -64,7 +66,7 @@ Then finally, a `UserInfoAction`, an action builder, handles the work of reading 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. +This example uses `services.session.SessionService` to provide a `Future` based API around a session store. ### Distributed Data Session Store diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..75e09d0a0 --- /dev/null +++ b/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'play' + id 'idea' +} + +def playVersion = '2.6.9' +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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..01b8bf6b1f99cad9213fc495b33ad5bbab8efd20 GIT binary patch literal 54329 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

<5G2M1WZP5 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 \(.*\)$'` + 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/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..e95643d6a --- /dev/null +++ b/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/scripts/script-helper b/scripts/script-helper new file mode 100644 index 000000000..ac39d9a51 --- /dev/null +++ b/scripts/script-helper @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -e +set -o pipefail + +java_version=$(java -version 2>&1 | java -version 2>&1 | awk -F '"' '/version/ {print $2}') + +if [[ $java_version = 9* ]] ; then + echo "The build is using Java 9 ($java_version). We need additional JVM parameters" + export _JAVA_OPTIONS="$_JAVA_OPTIONS --add-modules=java.xml.bind" +else + echo "The build is NOT using Java 9 ($java_version). No addional JVM params needed." +fi diff --git a/scripts/test-gradle b/scripts/test-gradle new file mode 100755 index 000000000..298c7ebe1 --- /dev/null +++ b/scripts/test-gradle @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +. "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/script-helper" + +# Using cut because TRAVIS_SCALA_VERSION is the full Scala +# version (for example 2.12.4), but Gradle expects just the +# binary version (for example 2.12) +scala_binary_version=$(echo $TRAVIS_SCALA_VERSION | cut -c1-4) + +echo "+------------------------------+" +echo "| Executing tests using Gradle |" +echo "+------------------------------+" +./gradlew -Dscala.binary.version=$scala_binary_version test -i --stacktrace diff --git a/scripts/test-sbt b/scripts/test-sbt new file mode 100755 index 000000000..0425367b1 --- /dev/null +++ b/scripts/test-sbt @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +. "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/script-helper" + +echo "+----------------------------+" +echo "| Executing tests using sbt |" +echo "+----------------------------+" +sbt ++$TRAVIS_SCALA_VERSION test From 0d63cb05884669200d986ecce7d9f57e413ff77e Mon Sep 17 00:00:00 2001 From: Greg Methvin Date: Thu, 21 Dec 2017 17:25:36 -0800 Subject: [PATCH 49/70] Updated with template-control on 2017-12-21T20:59:17.286Z (#57) **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.10") --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 51cd14953..bd7231813 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.9") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.10") From d2302ee278c8c28b0cf8162e4e9fa2b9f570550c Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Tue, 2 Jan 2018 15:46:50 -0200 Subject: [PATCH 50/70] Upgrade branch 2.6.x using TemplateControl (#58) * Updated with template-control on 2017-12-22T16:49:12.648Z **/test-gradle: ./gradlew -Dscala.binary.version=$scala_binary_version check -i --stacktrace * Add run permission to test-gradle script --- scripts/test-gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-gradle b/scripts/test-gradle index 298c7ebe1..84a051a20 100755 --- a/scripts/test-gradle +++ b/scripts/test-gradle @@ -10,4 +10,4 @@ scala_binary_version=$(echo $TRAVIS_SCALA_VERSION | cut -c1-4) echo "+------------------------------+" echo "| Executing tests using Gradle |" echo "+------------------------------+" -./gradlew -Dscala.binary.version=$scala_binary_version test -i --stacktrace +./gradlew -Dscala.binary.version=$scala_binary_version check -i --stacktrace From ec8edc3fedc61a0cfeea7ce51ad966438fe5547c Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Tue, 9 Jan 2018 21:01:48 -0200 Subject: [PATCH 51/70] Upgrade branch 2.6.x using TemplateControl (#59) * Updated with template-control on 2018-01-09T20:17:23.764Z **/build.properties: sbt.version=1.1.0 **build.gradle: def playVersion = "2.6.10" * test-gradle-permissions --- build.gradle | 2 +- project/build.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 75e09d0a0..7af53d1ba 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { id 'idea' } -def playVersion = '2.6.9' +def playVersion = "2.6.10" def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") model { diff --git a/project/build.properties b/project/build.properties index 394cb75cf..8b697bbb9 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.0.4 +sbt.version=1.1.0 From c1775d2b6b716dda4c70d53efe47becff44410ed Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Thu, 11 Jan 2018 17:15:07 -0200 Subject: [PATCH 52/70] Fix cross build to sbt 1.1.0 (#60) --- build.sbt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.sbt b/build.sbt index c37240c34..393cd0af1 100644 --- a/build.sbt +++ b/build.sbt @@ -6,6 +6,8 @@ lazy val root = (project in file(".")).enablePlugins(PlayScala) scalaVersion := "2.12.4" +crossScalaVersions := Seq("2.11.12", "2.12.4") + libraryDependencies += guice libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.6.0" libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data" % "2.5.8" From c002aad223af044fc2ebcb2c33b7090e6088f507 Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Fri, 12 Jan 2018 12:29:06 -0200 Subject: [PATCH 53/70] Upgrade branch 2.6.x using TemplateControl (#61) * Updated with template-control on 2018-01-11T21:32:27.527Z **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.11") **build.gradle: def playVersion = "2.6.11" * test-gradle-permissions --- build.gradle | 2 +- project/plugins.sbt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 7af53d1ba..66180d55a 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { id 'idea' } -def playVersion = "2.6.10" +def playVersion = "2.6.11" def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") model { diff --git a/project/plugins.sbt b/project/plugins.sbt index bd7231813..1ae01abd2 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.10") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.11") From 0098b858cfe270f5147eb311d0c568e7ccb83cba Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Fri, 2 Mar 2018 18:19:06 -0300 Subject: [PATCH 54/70] Updated with template-control on 2018-03-02T18:56:28.210Z (#62) **/build.properties: sbt.version=1.1.1 **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.12") **build.gradle: def playVersion = "2.6.12" --- build.gradle | 2 +- project/build.properties | 2 +- project/plugins.sbt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 66180d55a..c1154a5ef 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { id 'idea' } -def playVersion = "2.6.11" +def playVersion = "2.6.12" def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") model { diff --git a/project/build.properties b/project/build.properties index 8b697bbb9..31334bbd3 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.1.0 +sbt.version=1.1.1 diff --git a/project/plugins.sbt b/project/plugins.sbt index 1ae01abd2..743bbae21 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.11") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.12") From 7a8c9c32f72f78ac8195086f75f4e70e97a10458 Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Sat, 7 Apr 2018 10:52:03 -0300 Subject: [PATCH 55/70] Updated with template-control on 2018-04-06T19:34:58.475Z (#63) **/build.properties: sbt.version=1.1.2 **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.13") **build.gradle: def playVersion = "2.6.13" --- build.gradle | 2 +- project/build.properties | 2 +- project/plugins.sbt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index c1154a5ef..2db2cb383 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { id 'idea' } -def playVersion = "2.6.12" +def playVersion = "2.6.13" def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") model { diff --git a/project/build.properties b/project/build.properties index 31334bbd3..05313438a 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.1.1 +sbt.version=1.1.2 diff --git a/project/plugins.sbt b/project/plugins.sbt index 743bbae21..9e7a4ff83 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.12") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.13") From 98c1872dd0b6b31126afcb78bca5614a87969add Mon Sep 17 00:00:00 2001 From: Rich Dougherty Date: Tue, 29 May 2018 03:43:06 +1200 Subject: [PATCH 56/70] Updated with template-control on 2018-05-27T23:54:09.617Z (#65) **build.sbt: scalaVersion := "2.12.6" **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.15") **build.gradle: def playVersion = "2.6.15" --- build.gradle | 2 +- build.sbt | 2 +- project/plugins.sbt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 2db2cb383..f38442791 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { id 'idea' } -def playVersion = "2.6.13" +def playVersion = "2.6.15" def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") model { diff --git a/build.sbt b/build.sbt index 393cd0af1..893577d86 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,7 @@ version := "1.0-SNAPSHOT" lazy val root = (project in file(".")).enablePlugins(PlayScala) -scalaVersion := "2.12.4" +scalaVersion := "2.12.6" crossScalaVersions := Seq("2.11.12", "2.12.4") diff --git a/project/plugins.sbt b/project/plugins.sbt index 9e7a4ff83..adecc1828 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.13") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.15") From 44fca909c7fc3aa491a8e7147ea792beaa35d8a3 Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Sat, 9 Jun 2018 00:13:31 -0400 Subject: [PATCH 57/70] Upgrade branch 2.6.x using TemplateControl (#66) * Updated with template-control on 2018-06-08T21:15:48.904Z **/build.properties: sbt.version=1.1.6 * Add Java 10 and 11 to Travis build --- .travis.yml | 15 +++++++++++++++ gradle/wrapper/gradle-wrapper.properties | 2 +- project/build.properties | 2 +- scripts/script-helper | 8 ++++---- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index fc4811b19..7652b7151 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,9 +7,12 @@ scala: # in build matrix below. - 2.11.12 - 2.12.4 +- 2.12.6 jdk: - oraclejdk8 - oraclejdk9 +- oraclejdk10 +- oraclejdk11 before_install: - sudo add-apt-repository -y ppa:ondrej/php - sudo apt-get -qq update @@ -35,6 +38,18 @@ matrix: exclude: - scala: 2.11.12 jdk: oraclejdk9 + - scala: 2.11.12 + jdk: oraclejdk10 + - scala: 2.11.12 + jdk: oraclejdk11 + allow_failures: + # We should allow failures here since Java 11 removed some modules including + # java.xml.bind which we are adding when running with Java 9+. For more details + # see http://openjdk.java.net/jeps/320 + # + # Play already has a fix for that, but it needs to be backported and released + # for 2.6.x: https://github.com/playframework/playframework/pull/8382 + - jdk: oraclejdk11 # See https://blog.travis-ci.com/2014-03-13-slack-notifications/ # created with travis encrypt command line tool diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5ce78ed19..5a17e7455 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,4 @@ -distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.7-bin.zip distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStorePath=wrapper/dists diff --git a/project/build.properties b/project/build.properties index 05313438a..d6e35076c 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.1.2 +sbt.version=1.1.6 diff --git a/scripts/script-helper b/scripts/script-helper index ac39d9a51..9a2faa643 100644 --- a/scripts/script-helper +++ b/scripts/script-helper @@ -5,9 +5,9 @@ set -o pipefail java_version=$(java -version 2>&1 | java -version 2>&1 | awk -F '"' '/version/ {print $2}') -if [[ $java_version = 9* ]] ; then - echo "The build is using Java 9 ($java_version). We need additional JVM parameters" - export _JAVA_OPTIONS="$_JAVA_OPTIONS --add-modules=java.xml.bind" +if [[ $java_version = 1.8* ]] ; then + echo "The build is using Java 8 ($java_version). No addional JVM params needed." else - echo "The build is NOT using Java 9 ($java_version). No addional JVM params needed." + echo "The build is using Java 9+ ($java_version). We need additional JVM parameters" + export _JAVA_OPTIONS="$_JAVA_OPTIONS --add-modules=java.xml.bind" fi From 335571a44c2bd06fa0f3daaebfba5aef2e708663 Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Mon, 16 Jul 2018 17:55:57 -0400 Subject: [PATCH 58/70] Updated with template-control on 2018-07-16T18:38:45.458Z (#68) **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.16") **build.gradle: def playVersion = "2.6.16" --- build.gradle | 2 +- project/plugins.sbt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index f38442791..302ad6135 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { id 'idea' } -def playVersion = "2.6.15" +def playVersion = "2.6.16" def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") model { diff --git a/project/plugins.sbt b/project/plugins.sbt index adecc1828..5a07ff4c1 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.15") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.16") From d99c49acc0b591e9cd2222320af0bae8240b21bc Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Thu, 19 Jul 2018 12:26:22 -0400 Subject: [PATCH 59/70] Updated with template-control on 2018-07-19T01:58:55.998Z (#69) **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.17") **build.gradle: def playVersion = "2.6.17" --- build.gradle | 2 +- project/plugins.sbt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 302ad6135..92cf56cb6 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { id 'idea' } -def playVersion = "2.6.16" +def playVersion = "2.6.17" def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") model { diff --git a/project/plugins.sbt b/project/plugins.sbt index 5a07ff4c1..65e9b7203 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.16") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.17") From 4eff8d81c2d70ea0798886ee2394cd577943dcf2 Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Mon, 20 Aug 2018 19:37:15 -0400 Subject: [PATCH 60/70] Updated with template-control on 2018-08-20T20:37:13.861Z (#71) **/build.properties: sbt.version=1.2.1 **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.18") **build.gradle: def playVersion = "2.6.18" **gradle/wrapper/gradle-wrapper.properties: distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-bin.zip --- build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- project/build.properties | 2 +- project/plugins.sbt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 92cf56cb6..aa0c97c44 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { id 'idea' } -def playVersion = "2.6.17" +def playVersion = "2.6.18" def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") model { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5a17e7455..89dba2d9d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,4 @@ -distributionUrl=https\://services.gradle.org/distributions/gradle-4.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-bin.zip distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStorePath=wrapper/dists diff --git a/project/build.properties b/project/build.properties index d6e35076c..5620cc502 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.1.6 +sbt.version=1.2.1 diff --git a/project/plugins.sbt b/project/plugins.sbt index 65e9b7203..a6aaa8a6a 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.17") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.18") From 4946ab1de5e0c53de6dd319a45e35095a553b544 Mon Sep 17 00:00:00 2001 From: Renato Cavalcanti Date: Wed, 28 Nov 2018 16:54:20 +0100 Subject: [PATCH 61/70] Upgrade branch 2.6.x using TemplateControl (#77) ``` Updated with template-control on 2018-11-16T14:25:14.321Z **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.20") **build.gradle: def playVersion = "2.6.20" ``` --- .travis.yml | 20 ++++---------------- build.gradle | 4 ++-- build.sbt | 6 ++---- project/plugins.sbt | 2 +- scripts/script-helper | 13 ------------- scripts/test-gradle | 11 ++++------- scripts/test-sbt | 5 +++-- 7 files changed, 16 insertions(+), 45 deletions(-) delete mode 100644 scripts/script-helper diff --git a/.travis.yml b/.travis.yml index 7652b7151..ebdcb5e84 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,16 +3,10 @@ sudo: true group: beta language: scala scala: -# When updating Scala versions, also check the excludes -# in build matrix below. -- 2.11.12 -- 2.12.4 -- 2.12.6 +- 2.12.7 jdk: - oraclejdk8 -- oraclejdk9 -- oraclejdk10 -- oraclejdk11 +- openjdk11 before_install: - sudo add-apt-repository -y ppa:ondrej/php - sudo apt-get -qq update @@ -35,13 +29,7 @@ before_cache: # Exclude some combinations from build matrix. See: # https://docs.travis-ci.com/user/customizing-the-build/#Build-Matrix matrix: - exclude: - - scala: 2.11.12 - jdk: oraclejdk9 - - scala: 2.11.12 - jdk: oraclejdk10 - - scala: 2.11.12 - jdk: oraclejdk11 + fast_finish: true allow_failures: # We should allow failures here since Java 11 removed some modules including # java.xml.bind which we are adding when running with Java 9+. For more details @@ -49,7 +37,7 @@ matrix: # # Play already has a fix for that, but it needs to be backported and released # for 2.6.x: https://github.com/playframework/playframework/pull/8382 - - jdk: oraclejdk11 + - jdk: openjdk11 # See https://blog.travis-ci.com/2014-03-13-slack-notifications/ # created with travis encrypt command line tool diff --git a/build.gradle b/build.gradle index aa0c97c44..fafd01938 100644 --- a/build.gradle +++ b/build.gradle @@ -3,8 +3,8 @@ plugins { id 'idea' } -def playVersion = "2.6.18" -def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") +def playVersion = "2.6.20" +def scalaVersion = "2.12" model { components { diff --git a/build.sbt b/build.sbt index 893577d86..ca4a74c03 100644 --- a/build.sbt +++ b/build.sbt @@ -4,13 +4,11 @@ version := "1.0-SNAPSHOT" lazy val root = (project in file(".")).enablePlugins(PlayScala) -scalaVersion := "2.12.6" - -crossScalaVersions := Seq("2.11.12", "2.12.4") +scalaVersion := "2.12.7" libraryDependencies += guice libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.6.0" -libraryDependencies += "com.typesafe.akka" %% "akka-distributed-data" % "2.5.8" +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/project/plugins.sbt b/project/plugins.sbt index a6aaa8a6a..3dec70378 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.18") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.20") diff --git a/scripts/script-helper b/scripts/script-helper deleted file mode 100644 index 9a2faa643..000000000 --- a/scripts/script-helper +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -set -e -set -o pipefail - -java_version=$(java -version 2>&1 | java -version 2>&1 | awk -F '"' '/version/ {print $2}') - -if [[ $java_version = 1.8* ]] ; then - echo "The build is using Java 8 ($java_version). No addional JVM params needed." -else - echo "The build is using Java 9+ ($java_version). We need additional JVM parameters" - export _JAVA_OPTIONS="$_JAVA_OPTIONS --add-modules=java.xml.bind" -fi diff --git a/scripts/test-gradle b/scripts/test-gradle index 84a051a20..de9857a71 100755 --- a/scripts/test-gradle +++ b/scripts/test-gradle @@ -1,13 +1,10 @@ #!/usr/bin/env bash -. "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/script-helper" - -# Using cut because TRAVIS_SCALA_VERSION is the full Scala -# version (for example 2.12.4), but Gradle expects just the -# binary version (for example 2.12) -scala_binary_version=$(echo $TRAVIS_SCALA_VERSION | cut -c1-4) +set -e +set -o pipefail echo "+------------------------------+" echo "| Executing tests using Gradle |" echo "+------------------------------+" -./gradlew -Dscala.binary.version=$scala_binary_version check -i --stacktrace + +./gradlew check -i --stacktrace diff --git a/scripts/test-sbt b/scripts/test-sbt index 0425367b1..3a83bab73 100755 --- a/scripts/test-sbt +++ b/scripts/test-sbt @@ -1,8 +1,9 @@ #!/usr/bin/env bash -. "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/script-helper" +set -e +set -o pipefail echo "+----------------------------+" echo "| Executing tests using sbt |" echo "+----------------------------+" -sbt ++$TRAVIS_SCALA_VERSION test +sbt test From a52e3c8492e6b987105e2b58ef384948bc3f1c98 Mon Sep 17 00:00:00 2001 From: Renato Cavalcanti Date: Wed, 28 Nov 2018 22:38:50 +0100 Subject: [PATCH 62/70] adds mergify file (#79) --- .mergify.yml | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .mergify.yml diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 000000000..5228c152f --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,49 @@ +pull_request_rules: + - name: automatic merge on CI success require review + conditions: + - status-success=continuous-integration/travis-ci/pr + - "#approved-reviews-by>=1" + - "#changes-requested-reviews-by=0" + - label=merge-when-green + - label!=work-in-progress + actions: + merge: + method: squash + strict: smart + + - name: automatic merge on CI success for TemplateControl + conditions: + - status-success=continuous-integration/travis-ci/pr + - label=merge-when-green + - label=template-control + - label!=work-in-progress + actions: + merge: + method: squash + strict: smart + + # delete any branch when already merged + # doesn't matter if marked with labels or not + - name: delete branch after merge + conditions: + - merged + actions: + delete_head_branch: {} + + # delete 'merge-when-green' label if present and merged + - name: remove label after merge + conditions: + - merged + - label=merge-when-green + actions: + label: + remove: [merge-when-green] + + # delete 'template-control' label if present and merged + - name: remove label after merge + conditions: + - merged + - label=template-control + actions: + label: + remove: [template-control] \ No newline at end of file From 11fbc44bd35cf9d751fe9c3ac1761daa5063a4c9 Mon Sep 17 00:00:00 2001 From: Renato Cavalcanti Date: Thu, 29 Nov 2018 20:44:40 +0100 Subject: [PATCH 63/70] Upgrade branch 2.6.x using TemplateControl (#81) ``` Updated with template-control on 2018-11-29T16:01:48.151Z /.mergify.yml: wrote /.mergify.yml **build.gradle: def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") ``` --- .mergify.yml | 14 +++++++++++--- build.gradle | 2 +- project/build.properties | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 5228c152f..4a37a16dd 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -5,7 +5,7 @@ pull_request_rules: - "#approved-reviews-by>=1" - "#changes-requested-reviews-by=0" - label=merge-when-green - - label!=work-in-progress + - label!=block-merge actions: merge: method: squash @@ -16,7 +16,7 @@ pull_request_rules: - status-success=continuous-integration/travis-ci/pr - label=merge-when-green - label=template-control - - label!=work-in-progress + - label!=block-merge actions: merge: method: squash @@ -46,4 +46,12 @@ pull_request_rules: - label=template-control actions: label: - remove: [template-control] \ No newline at end of file + remove: [template-control] + + - name: auto add wip + conditions: + # match a few flavours of wip + - title~=^(\[wip\]( |:) |\[WIP\]( |:) |wip( |:) |WIP( |:)).* + actions: + label: + add: ["block-merge"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index fafd01938..4ac52cd3a 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { } def playVersion = "2.6.20" -def scalaVersion = "2.12" +def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") model { components { diff --git a/project/build.properties b/project/build.properties index 5620cc502..7c58a83ab 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.2.1 +sbt.version=1.2.6 From a2810bdb7d1fb8f7346cb29699dc3d17476df969 Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Fri, 4 Jan 2019 15:50:02 -0500 Subject: [PATCH 64/70] Upgrade branch 2.6.x using TemplateControl (#82) * Updated with template-control on 2019-01-04T17:13:23.856Z /.travis.yml: wrote /.travis.yml **build.sbt: scalaVersion := "2.12.8" **/build.properties: sbt.version=1.2.8 * Keep libsodium install --- .travis.yml | 75 ++++++++++++++++++++++------------------ build.sbt | 2 +- project/build.properties | 2 +- 3 files changed, 43 insertions(+), 36 deletions(-) diff --git a/.travis.yml b/.travis.yml index ebdcb5e84..556608613 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,46 +1,53 @@ -dist: trusty -sudo: true -group: beta language: scala scala: -- 2.12.7 -jdk: -- oraclejdk8 -- openjdk11 + - 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 + - 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: - - SCRIPT=scripts/test-sbt - - SCRIPT=scripts/test-gradle -script: -- $SCRIPT -cache: - directories: - - "$HOME/.ivy2/cache" - - "$HOME/.gradle/caches" -before_cache: - - rm -rf $HOME/.ivy2/cache/com.typesafe.play/* - - rm -rf $HOME/.ivy2/cache/scala_*/sbt_*/com.typesafe.play/* - - find $HOME/.ivy2/cache -name "ivydata-*.properties" -print0 | xargs -n10 -0 rm + # 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: - # We should allow failures here since Java 11 removed some modules including - # java.xml.bind which we are adding when running with Java 9+. For more details - # see http://openjdk.java.net/jeps/320 - # - # Play already has a fix for that, but it needs to be backported and released - # for 2.6.x: https://github.com/playframework/playframework/pull/8382 - - jdk: openjdk11 + # 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 -# See https://blog.travis-ci.com/2014-03-13-slack-notifications/ -# created with travis encrypt command line tool -notifications: - slack: - secure: D5Mj39P4P/mCRk/rSThkRRLKRCnv4qwY1ZMgYAljXAcJQDX2dFe7ZhUHeNHL02jGTL6QJqyM2lgKA1+yXjphmabqYB/fJmmaHZEx3c9XcfhRVdveIANdzVBfaHuM1YOzpx6LCO/6YlHHTxPMyBxl3q9ELguKs04nja0NTiKeSVAS7+rI8RUciqZ41zzq32PrdWmsPB76yxvftdHtCrTjIepnKfUfU/xvekWKybk0L6tj9P/rSu7Ao6pqJCRw8ct954dKHEY01C9tw3lyc7f9/kNsFVRq4A8+tuiGB/yM5pRHgmxbAzjQRyCUjccDgMf8P+NmSenb/JIMQcPt/ZR/dzABrzgQe225Y3b53IeuOyMO2j6ZtBUwJ27OQdXeAiY5VtUqcG3nmlrjQkSyjqWz7Frjj4Trgqlv1/9lSJs+ciwOiYcBjTew9zrVlfyqmza32VPKOvvStTrtYpSS99c4KkiOeEKZGX45yVMnqEdn4g6ox3bYj6oGst0t87KiX+rvnmPfsE1c+RfCI7M7wAieAeXQsX5PaLXQLKG0VedDJe8x6IP+zd9AxrzKSNlcjo6SsBHO1h9VrTNd/WKYkzkjeF0UwfUXqmZErz0y+bvfF77ATw4vmyagSy60a8tJjekJYDB/Qu5IXnlWrg96niKpf3j4Jcyn1CVvlTnTD0sbZM4= +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/build.sbt b/build.sbt index ca4a74c03..84af4473b 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,7 @@ version := "1.0-SNAPSHOT" lazy val root = (project in file(".")).enablePlugins(PlayScala) -scalaVersion := "2.12.7" +scalaVersion := "2.12.8" libraryDependencies += guice libraryDependencies += "org.abstractj.kalium" % "kalium" % "0.6.0" diff --git a/project/build.properties b/project/build.properties index 7c58a83ab..c0bab0494 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.2.6 +sbt.version=1.2.8 From 147a4500dc917c39a79be1d40dbbf6776050eb26 Mon Sep 17 00:00:00 2001 From: Renato Cavalcanti Date: Wed, 9 Jan 2019 18:35:47 +0100 Subject: [PATCH 65/70] Upgrade branch 2.6.x using TemplateControl (#89) * Updated with template-control on 2019-01-08T14:44:41.677Z /.travis.yml: wrote /.travis.yml **/plugins.sbt: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.21") **build.gradle: def playVersion = "2.6.21" **build.gradle: playTest "org.scalatestplus.play:scalatestplus-play_\$scalaVersion:3.1.2" * Keep libsodium install --- build.gradle | 4 ++-- project/plugins.sbt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 4ac52cd3a..e0c1f7ac6 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { id 'idea' } -def playVersion = "2.6.20" +def playVersion = "2.6.21" def scalaVersion = System.getProperty("scala.binary.version", /* default = */ "2.12") model { @@ -30,7 +30,7 @@ dependencies { 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" + playTest "org.scalatestplus.play:scalatestplus-play_\$scalaVersion:3.1.2" } repositories { diff --git a/project/plugins.sbt b/project/plugins.sbt index 3dec70378..372755e6f 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ // The Play plugin -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.20") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.21") From c29910104363f736e82982208a75329133871171 Mon Sep 17 00:00:00 2001 From: Renato Cavalcanti Date: Wed, 16 Jan 2019 13:52:59 +0100 Subject: [PATCH 66/70] Upgrade branch 2.6.x using TemplateControl (#91) ``` Updated with template-control on 2019-01-16T12:08:45.963Z /LICENSE: wrote /LICENSE /NOTICE: wrote /NOTICE /.mergify.yml: wrote /.mergify.yml **build.gradle: playTest "org.scalatestplus.play:scalatestplus-play_$scalaVersion:3.1.2" ``` --- .mergify.yml | 10 ++--- LICENSE | 119 ++++++++++++++++++++++++++++++++++++++++++++++++--- NOTICE | 8 ++++ build.gradle | 2 +- 4 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 NOTICE diff --git a/.mergify.yml b/.mergify.yml index 4a37a16dd..3549efd41 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -22,16 +22,13 @@ pull_request_rules: method: squash strict: smart - # delete any branch when already merged - # doesn't matter if marked with labels or not - name: delete branch after merge conditions: - merged actions: delete_head_branch: {} - # delete 'merge-when-green' label if present and merged - - name: remove label after merge + - name: remove merge-when-green label after merge conditions: - merged - label=merge-when-green @@ -39,8 +36,7 @@ pull_request_rules: label: remove: [merge-when-green] - # delete 'template-control' label if present and merged - - name: remove label after merge + - name: remove template-control label after merge conditions: - merged - label=template-control @@ -54,4 +50,4 @@ pull_request_rules: - title~=^(\[wip\]( |:) |\[WIP\]( |:) |wip( |:) |WIP( |:)).* actions: label: - add: ["block-merge"] \ No newline at end of file + add: ["block-merge"] diff --git a/LICENSE b/LICENSE index b018ae2bc..670154e35 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,116 @@ -License -------- -Written in 2016 by Lightbend +CC0 1.0 Universal -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. +Statement of Purpose -You should have received a copy of the CC0 Public Domain Dedication along with this software. If not, see . +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/NOTICE b/NOTICE new file mode 100644 index 000000000..6d6c034d3 --- /dev/null +++ b/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/build.gradle b/build.gradle index e0c1f7ac6..2fbdfce11 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ dependencies { 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" + playTest "org.scalatestplus.play:scalatestplus-play_$scalaVersion:3.1.2" } repositories { From 56907fdc1cd6d6ae712c0ac3a20d29b6f8be9c13 Mon Sep 17 00:00:00 2001 From: Renato Cavalcanti Date: Thu, 17 Jan 2019 19:31:31 +0100 Subject: [PATCH 67/70] Upgrade branch 2.6.x using TemplateControl (#92) ``` Updated with template-control on 2019-01-17T15:10:57.744Z /.mergify.yml: wrote /.mergify.yml ``` --- .mergify.yml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 3549efd41..b215a7709 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -4,7 +4,6 @@ pull_request_rules: - status-success=continuous-integration/travis-ci/pr - "#approved-reviews-by>=1" - "#changes-requested-reviews-by=0" - - label=merge-when-green - label!=block-merge actions: merge: @@ -15,7 +14,6 @@ pull_request_rules: conditions: - status-success=continuous-integration/travis-ci/pr - label=merge-when-green - - label=template-control - label!=block-merge actions: merge: @@ -35,19 +33,3 @@ pull_request_rules: actions: label: remove: [merge-when-green] - - - name: remove template-control label after merge - conditions: - - merged - - label=template-control - actions: - label: - remove: [template-control] - - - name: auto add wip - conditions: - # match a few flavours of wip - - title~=^(\[wip\]( |:) |\[WIP\]( |:) |wip( |:) |WIP( |:)).* - actions: - label: - add: ["block-merge"] From c0115ff2cce8a1d912ee10ccd8a07b296470d0c1 Mon Sep 17 00:00:00 2001 From: Renato Cavalcanti Date: Fri, 1 Feb 2019 14:24:35 +0100 Subject: [PATCH 68/70] Updated with template-control on 2019-02-01T10:40:32.758Z (#93) /.mergify.yml: wrote /.mergify.yml --- .mergify.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index b215a7709..fbbe4380f 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -25,11 +25,3 @@ pull_request_rules: - merged actions: delete_head_branch: {} - - - name: remove merge-when-green label after merge - conditions: - - merged - - label=merge-when-green - actions: - label: - remove: [merge-when-green] From 9c59a23a1b6fbeedae21cc383ad0482bacc9d9dc Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Thu, 14 Feb 2019 00:39:48 -0500 Subject: [PATCH 69/70] Updated with template-control on 2019-02-13T20:17:46.435Z (#95) /.mergify.yml: wrote /.mergify.yml --- .mergify.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index fbbe4380f..32f8689ae 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,7 +1,7 @@ pull_request_rules: - name: automatic merge on CI success require review conditions: - - status-success=continuous-integration/travis-ci/pr + - status-success=Travis CI - Pull Request - "#approved-reviews-by>=1" - "#changes-requested-reviews-by=0" - label!=block-merge @@ -12,7 +12,7 @@ pull_request_rules: - name: automatic merge on CI success for TemplateControl conditions: - - status-success=continuous-integration/travis-ci/pr + - status-success=Travis CI - Pull Request - label=merge-when-green - label!=block-merge actions: From 2f7c675850fbfbb7ab1e85ee42359063264e19c3 Mon Sep 17 00:00:00 2001 From: Dale Wijnand Date: Tue, 23 Apr 2019 15:49:58 +0100 Subject: [PATCH 70/70] Nest play-scala-secure-session-example --- .../.gitignore | 0 .../.mergify.yml | 0 .../.travis.yml | 0 .../LICENSE | 0 NOTICE => play-scala-secure-session-example/NOTICE | 0 .../README.md | 0 .../app}/Module.scala | 0 .../app}/controllers/HomeController.scala | 0 .../app}/controllers/LoginController.scala | 0 .../app}/controllers/LogoutController.scala | 0 .../app}/controllers/package.scala | 0 .../services/encryption/EncryptedCookieBaker.scala | 0 .../services/encryption/EncryptionService.scala | 0 .../app}/services/encryption/Nonce.scala | 0 .../app}/services/session/ClusterSystem.scala | 0 .../app}/services/session/SessionCache.scala | 0 .../app}/services/session/SessionService.scala | 0 .../app}/views/index.scala.html | 0 .../app}/views/main.scala.html | 0 .../build.gradle | 0 .../build.sbt | 0 .../conf}/application.conf | 0 .../conf}/logback.xml | 0 .../conf}/routes | 0 .../gradle}/wrapper/gradle-wrapper.jar | Bin .../gradle}/wrapper/gradle-wrapper.properties | 0 .../gradlew | 0 .../gradlew.bat | 0 .../project}/build.properties | 0 .../project}/plugins.sbt | 0 .../public}/images/favicon.png | Bin .../public}/javascripts/hello.js | 0 .../public}/stylesheets/main.css | 0 .../scripts}/test-gradle | 0 .../scripts}/test-sbt | 0 .../services/encryption/EncryptionServiceSpec.scala | 0 36 files changed, 0 insertions(+), 0 deletions(-) rename .gitignore => play-scala-secure-session-example/.gitignore (100%) rename .mergify.yml => play-scala-secure-session-example/.mergify.yml (100%) rename .travis.yml => play-scala-secure-session-example/.travis.yml (100%) rename LICENSE => play-scala-secure-session-example/LICENSE (100%) rename NOTICE => play-scala-secure-session-example/NOTICE (100%) rename README.md => play-scala-secure-session-example/README.md (100%) rename {app => play-scala-secure-session-example/app}/Module.scala (100%) rename {app => play-scala-secure-session-example/app}/controllers/HomeController.scala (100%) rename {app => play-scala-secure-session-example/app}/controllers/LoginController.scala (100%) rename {app => play-scala-secure-session-example/app}/controllers/LogoutController.scala (100%) rename {app => play-scala-secure-session-example/app}/controllers/package.scala (100%) rename {app => play-scala-secure-session-example/app}/services/encryption/EncryptedCookieBaker.scala (100%) rename {app => play-scala-secure-session-example/app}/services/encryption/EncryptionService.scala (100%) rename {app => play-scala-secure-session-example/app}/services/encryption/Nonce.scala (100%) rename {app => play-scala-secure-session-example/app}/services/session/ClusterSystem.scala (100%) rename {app => play-scala-secure-session-example/app}/services/session/SessionCache.scala (100%) rename {app => play-scala-secure-session-example/app}/services/session/SessionService.scala (100%) rename {app => play-scala-secure-session-example/app}/views/index.scala.html (100%) rename {app => play-scala-secure-session-example/app}/views/main.scala.html (100%) rename build.gradle => play-scala-secure-session-example/build.gradle (100%) rename build.sbt => play-scala-secure-session-example/build.sbt (100%) rename {conf => play-scala-secure-session-example/conf}/application.conf (100%) rename {conf => play-scala-secure-session-example/conf}/logback.xml (100%) rename {conf => play-scala-secure-session-example/conf}/routes (100%) rename {gradle => play-scala-secure-session-example/gradle}/wrapper/gradle-wrapper.jar (100%) rename {gradle => play-scala-secure-session-example/gradle}/wrapper/gradle-wrapper.properties (100%) rename gradlew => play-scala-secure-session-example/gradlew (100%) rename gradlew.bat => play-scala-secure-session-example/gradlew.bat (100%) rename {project => play-scala-secure-session-example/project}/build.properties (100%) rename {project => play-scala-secure-session-example/project}/plugins.sbt (100%) rename {public => play-scala-secure-session-example/public}/images/favicon.png (100%) rename {public => play-scala-secure-session-example/public}/javascripts/hello.js (100%) rename {public => play-scala-secure-session-example/public}/stylesheets/main.css (100%) rename {scripts => play-scala-secure-session-example/scripts}/test-gradle (100%) rename {scripts => play-scala-secure-session-example/scripts}/test-sbt (100%) rename {test => play-scala-secure-session-example/test}/services/encryption/EncryptionServiceSpec.scala (100%) diff --git a/.gitignore b/play-scala-secure-session-example/.gitignore similarity index 100% rename from .gitignore rename to play-scala-secure-session-example/.gitignore diff --git a/.mergify.yml b/play-scala-secure-session-example/.mergify.yml similarity index 100% rename from .mergify.yml rename to play-scala-secure-session-example/.mergify.yml diff --git a/.travis.yml b/play-scala-secure-session-example/.travis.yml similarity index 100% rename from .travis.yml rename to play-scala-secure-session-example/.travis.yml diff --git a/LICENSE b/play-scala-secure-session-example/LICENSE similarity index 100% rename from LICENSE rename to play-scala-secure-session-example/LICENSE diff --git a/NOTICE b/play-scala-secure-session-example/NOTICE similarity index 100% rename from NOTICE rename to play-scala-secure-session-example/NOTICE diff --git a/README.md b/play-scala-secure-session-example/README.md similarity index 100% rename from README.md rename to play-scala-secure-session-example/README.md diff --git a/app/Module.scala b/play-scala-secure-session-example/app/Module.scala similarity index 100% rename from app/Module.scala rename to play-scala-secure-session-example/app/Module.scala diff --git a/app/controllers/HomeController.scala b/play-scala-secure-session-example/app/controllers/HomeController.scala similarity index 100% rename from app/controllers/HomeController.scala rename to play-scala-secure-session-example/app/controllers/HomeController.scala diff --git a/app/controllers/LoginController.scala b/play-scala-secure-session-example/app/controllers/LoginController.scala similarity index 100% rename from app/controllers/LoginController.scala rename to play-scala-secure-session-example/app/controllers/LoginController.scala diff --git a/app/controllers/LogoutController.scala b/play-scala-secure-session-example/app/controllers/LogoutController.scala similarity index 100% rename from app/controllers/LogoutController.scala rename to play-scala-secure-session-example/app/controllers/LogoutController.scala diff --git a/app/controllers/package.scala b/play-scala-secure-session-example/app/controllers/package.scala similarity index 100% rename from app/controllers/package.scala rename to play-scala-secure-session-example/app/controllers/package.scala diff --git a/app/services/encryption/EncryptedCookieBaker.scala b/play-scala-secure-session-example/app/services/encryption/EncryptedCookieBaker.scala similarity index 100% rename from app/services/encryption/EncryptedCookieBaker.scala rename to play-scala-secure-session-example/app/services/encryption/EncryptedCookieBaker.scala diff --git a/app/services/encryption/EncryptionService.scala b/play-scala-secure-session-example/app/services/encryption/EncryptionService.scala similarity index 100% rename from app/services/encryption/EncryptionService.scala rename to play-scala-secure-session-example/app/services/encryption/EncryptionService.scala diff --git a/app/services/encryption/Nonce.scala b/play-scala-secure-session-example/app/services/encryption/Nonce.scala similarity index 100% rename from app/services/encryption/Nonce.scala rename to play-scala-secure-session-example/app/services/encryption/Nonce.scala diff --git a/app/services/session/ClusterSystem.scala b/play-scala-secure-session-example/app/services/session/ClusterSystem.scala similarity index 100% rename from app/services/session/ClusterSystem.scala rename to play-scala-secure-session-example/app/services/session/ClusterSystem.scala diff --git a/app/services/session/SessionCache.scala b/play-scala-secure-session-example/app/services/session/SessionCache.scala similarity index 100% rename from app/services/session/SessionCache.scala rename to play-scala-secure-session-example/app/services/session/SessionCache.scala diff --git a/app/services/session/SessionService.scala b/play-scala-secure-session-example/app/services/session/SessionService.scala similarity index 100% rename from app/services/session/SessionService.scala rename to play-scala-secure-session-example/app/services/session/SessionService.scala diff --git a/app/views/index.scala.html b/play-scala-secure-session-example/app/views/index.scala.html similarity index 100% rename from app/views/index.scala.html rename to play-scala-secure-session-example/app/views/index.scala.html diff --git a/app/views/main.scala.html b/play-scala-secure-session-example/app/views/main.scala.html similarity index 100% rename from app/views/main.scala.html rename to play-scala-secure-session-example/app/views/main.scala.html diff --git a/build.gradle b/play-scala-secure-session-example/build.gradle similarity index 100% rename from build.gradle rename to play-scala-secure-session-example/build.gradle diff --git a/build.sbt b/play-scala-secure-session-example/build.sbt similarity index 100% rename from build.sbt rename to play-scala-secure-session-example/build.sbt diff --git a/conf/application.conf b/play-scala-secure-session-example/conf/application.conf similarity index 100% rename from conf/application.conf rename to play-scala-secure-session-example/conf/application.conf diff --git a/conf/logback.xml b/play-scala-secure-session-example/conf/logback.xml similarity index 100% rename from conf/logback.xml rename to play-scala-secure-session-example/conf/logback.xml diff --git a/conf/routes b/play-scala-secure-session-example/conf/routes similarity index 100% rename from conf/routes rename to play-scala-secure-session-example/conf/routes diff --git a/gradle/wrapper/gradle-wrapper.jar b/play-scala-secure-session-example/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from gradle/wrapper/gradle-wrapper.jar rename to play-scala-secure-session-example/gradle/wrapper/gradle-wrapper.jar diff --git a/gradle/wrapper/gradle-wrapper.properties b/play-scala-secure-session-example/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from gradle/wrapper/gradle-wrapper.properties rename to play-scala-secure-session-example/gradle/wrapper/gradle-wrapper.properties diff --git a/gradlew b/play-scala-secure-session-example/gradlew similarity index 100% rename from gradlew rename to play-scala-secure-session-example/gradlew diff --git a/gradlew.bat b/play-scala-secure-session-example/gradlew.bat similarity index 100% rename from gradlew.bat rename to play-scala-secure-session-example/gradlew.bat diff --git a/project/build.properties b/play-scala-secure-session-example/project/build.properties similarity index 100% rename from project/build.properties rename to play-scala-secure-session-example/project/build.properties diff --git a/project/plugins.sbt b/play-scala-secure-session-example/project/plugins.sbt similarity index 100% rename from project/plugins.sbt rename to play-scala-secure-session-example/project/plugins.sbt diff --git a/public/images/favicon.png b/play-scala-secure-session-example/public/images/favicon.png similarity index 100% rename from public/images/favicon.png rename to play-scala-secure-session-example/public/images/favicon.png diff --git a/public/javascripts/hello.js b/play-scala-secure-session-example/public/javascripts/hello.js similarity index 100% rename from public/javascripts/hello.js rename to play-scala-secure-session-example/public/javascripts/hello.js diff --git a/public/stylesheets/main.css b/play-scala-secure-session-example/public/stylesheets/main.css similarity index 100% rename from public/stylesheets/main.css rename to play-scala-secure-session-example/public/stylesheets/main.css diff --git a/scripts/test-gradle b/play-scala-secure-session-example/scripts/test-gradle similarity index 100% rename from scripts/test-gradle rename to play-scala-secure-session-example/scripts/test-gradle diff --git a/scripts/test-sbt b/play-scala-secure-session-example/scripts/test-sbt similarity index 100% rename from scripts/test-sbt rename to play-scala-secure-session-example/scripts/test-sbt diff --git a/test/services/encryption/EncryptionServiceSpec.scala b/play-scala-secure-session-example/test/services/encryption/EncryptionServiceSpec.scala similarity index 100% rename from test/services/encryption/EncryptionServiceSpec.scala rename to play-scala-secure-session-example/test/services/encryption/EncryptionServiceSpec.scala