From 7e3d0c6b7803908e7bb9573637190fd039d7d494 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Sat, 19 Aug 2023 18:19:18 +0100 Subject: [PATCH] pekko-http-session package names Update SessionInvalidationScala.scala --- pekko-http-session/build.sbt | 94 ++ .../javadsl/CsrfDirectives.scala | 35 + .../javadsl/HttpSessionAwareDirectives.java | 59 ++ .../javadsl/InMemoryRefreshTokenStorage.scala | 7 + .../javadsl/SessionDirectives.scala | 57 ++ .../javadsl/SessionSerializers.java | 34 + .../javadsl/SessionTransports.java | 18 + .../core/src/main/resources/reference.conf | 60 ++ .../pekkohttpsession/Crypto.scala | 45 + .../pekkohttpsession/CsrfDirectives.scala | 79 ++ .../pekkohttpsession/JwsAlgorithm.scala | 76 ++ .../RefreshTokenStorage.scala | 65 ++ .../pekkohttpsession/SessionConfig.scala | 172 ++++ .../pekkohttpsession/SessionContinuity.scala | 18 + .../pekkohttpsession/SessionDirectives.scala | 243 ++++++ .../pekkohttpsession/SessionEncoder.scala | 85 ++ .../pekkohttpsession/SessionManager.scala | 226 +++++ .../pekkohttpsession/SessionSerializer.scala | 92 ++ .../pekkohttpsession/SessionTransport.scala | 7 + .../pekkohttpsession/SessionUtil.scala | 70 ++ .../converters/MapConverters.scala | 9 + .../pekkohttpsession/CsrfDirectivesTest.scala | 176 ++++ .../pekkohttpsession/Legacy.scala | 48 ++ .../MultipleTransportTest.scala | 78 ++ .../OneOffSetRefreshableGetTest.scala | 104 +++ .../pekkohttpsession/OneOffTest.scala | 222 +++++ .../pekkohttpsession/RefreshableTest.scala | 322 +++++++ .../pekkohttpsession/SessionConfigTest.scala | 170 ++++ .../SessionManagerBasicEncoderTest.scala | 103 +++ .../pekkohttpsession/TestData.scala | 27 + .../pekkoexample/JavaExample.java | 160 ++++ .../pekkoexample/SomeJavaComplexObject.java | 14 + .../pekkoexample/jwt/JavaJwtExample.java | 160 ++++ .../MyJavaMultiValueSessionSerializer.java | 34 + .../MyJavaSingleSessionSerializer.java | 29 + .../pekkoexample/session/MyJavaSession.java | 38 + .../session/SessionInvalidationJava.java | 113 +++ .../pekkoexample/session/SetSessionJava.java | 111 +++ .../session/VariousSessionsJava.java | 112 +++ .../session/manager/MyJavaSessionManager.java | 16 + .../example/src/main/resources/index.html | 99 +++ .../example/src/main/resources/jquery.min.js | 4 + .../example/src/main/resources/js-cookie.js | 137 +++ .../pekkoexample/ScalaExample.scala | 95 ++ .../pekkoexample/SomeScalaComplexObject.scala | 3 + .../serializers/JWTSerializersScala.scala | 11 + .../MyScalaMultiValueSessionSerializer.scala | 11 + .../MyScalaSingleSessionSerializer.scala | 14 + .../pekkoexample/session/MyScalaSession.scala | 16 + .../session/SessionInvalidationScala.scala | 57 ++ .../session/SetSessionScala.scala | 66 ++ .../session/VariousSessionsScala.scala | 107 +++ .../manager/JWTSessionManagerScala.scala | 15 + .../manager/MyScalaSessionManager.scala | 12 + .../javadsl/CsrfDirectivesTest.java | 263 ++++++ .../HttpSessionAwareDirectivesTest.java | 223 +++++ .../javadsl/OneOffSetRefreshableGetTest.java | 233 +++++ .../pekkohttpsession/javadsl/OneOffTest.java | 493 +++++++++++ .../javadsl/RefreshableTest.java | 808 ++++++++++++++++++ .../javadsl/JwtSessionSerializers.java | 24 + .../JValueSessionSerializer.scala | 44 + .../pekkohttpsession/JwtSessionEncoder.scala | 136 +++ .../SessionManagerJwtEncoderTest.scala | 245 ++++++ pekko-http-session/project/build.properties | 1 + pekko-http-session/project/plugins.sbt | 8 + 65 files changed, 6713 insertions(+) create mode 100644 pekko-http-session/build.sbt create mode 100644 pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/CsrfDirectives.scala create mode 100644 pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/HttpSessionAwareDirectives.java create mode 100644 pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/InMemoryRefreshTokenStorage.scala create mode 100644 pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/SessionDirectives.scala create mode 100644 pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/SessionSerializers.java create mode 100644 pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/SessionTransports.java create mode 100644 pekko-http-session/core/src/main/resources/reference.conf create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/Crypto.scala create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/CsrfDirectives.scala create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/JwsAlgorithm.scala create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/RefreshTokenStorage.scala create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionConfig.scala create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionContinuity.scala create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionDirectives.scala create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionEncoder.scala create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionManager.scala create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionSerializer.scala create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionTransport.scala create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionUtil.scala create mode 100644 pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/converters/MapConverters.scala create mode 100644 pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/CsrfDirectivesTest.scala create mode 100644 pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/Legacy.scala create mode 100644 pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/MultipleTransportTest.scala create mode 100644 pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/OneOffSetRefreshableGetTest.scala create mode 100644 pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/OneOffTest.scala create mode 100644 pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/RefreshableTest.scala create mode 100644 pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/SessionConfigTest.scala create mode 100644 pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/SessionManagerBasicEncoderTest.scala create mode 100644 pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/TestData.scala create mode 100644 pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/JavaExample.java create mode 100644 pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/SomeJavaComplexObject.java create mode 100644 pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/jwt/JavaJwtExample.java create mode 100644 pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/serializers/MyJavaMultiValueSessionSerializer.java create mode 100644 pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/serializers/MyJavaSingleSessionSerializer.java create mode 100644 pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/MyJavaSession.java create mode 100644 pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/SessionInvalidationJava.java create mode 100644 pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/SetSessionJava.java create mode 100644 pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/VariousSessionsJava.java create mode 100644 pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/manager/MyJavaSessionManager.java create mode 100644 pekko-http-session/example/src/main/resources/index.html create mode 100644 pekko-http-session/example/src/main/resources/jquery.min.js create mode 100644 pekko-http-session/example/src/main/resources/js-cookie.js create mode 100644 pekko-http-session/example/src/main/scala/com/softwaremill/pekkoexample/ScalaExample.scala create mode 100644 pekko-http-session/example/src/main/scala/com/softwaremill/pekkoexample/SomeScalaComplexObject.scala create mode 100644 pekko-http-session/example/src/main/scala/com/softwaremill/pekkoexample/serializers/JWTSerializersScala.scala create mode 100644 pekko-http-session/example/src/main/scala/com/softwaremill/pekkoexample/serializers/MyScalaMultiValueSessionSerializer.scala create mode 100644 pekko-http-session/example/src/main/scala/com/softwaremill/pekkoexample/serializers/MyScalaSingleSessionSerializer.scala create mode 100644 pekko-http-session/example/src/main/scala/com/softwaremill/pekkoexample/session/MyScalaSession.scala create mode 100644 pekko-http-session/example/src/main/scala/com/softwaremill/pekkoexample/session/SessionInvalidationScala.scala create mode 100644 pekko-http-session/example/src/main/scala/com/softwaremill/pekkoexample/session/SetSessionScala.scala create mode 100644 pekko-http-session/example/src/main/scala/com/softwaremill/pekkoexample/session/VariousSessionsScala.scala create mode 100644 pekko-http-session/example/src/main/scala/com/softwaremill/pekkoexample/session/manager/JWTSessionManagerScala.scala create mode 100644 pekko-http-session/example/src/main/scala/com/softwaremill/pekkoexample/session/manager/MyScalaSessionManager.scala create mode 100644 pekko-http-session/javaTests/src/test/java/com/softwaremill/pekkohttpsession/javadsl/CsrfDirectivesTest.java create mode 100644 pekko-http-session/javaTests/src/test/java/com/softwaremill/pekkohttpsession/javadsl/HttpSessionAwareDirectivesTest.java create mode 100644 pekko-http-session/javaTests/src/test/java/com/softwaremill/pekkohttpsession/javadsl/OneOffSetRefreshableGetTest.java create mode 100644 pekko-http-session/javaTests/src/test/java/com/softwaremill/pekkohttpsession/javadsl/OneOffTest.java create mode 100644 pekko-http-session/javaTests/src/test/java/com/softwaremill/pekkohttpsession/javadsl/RefreshableTest.java create mode 100644 pekko-http-session/jwt/src/main/java/com/softwaremill/pekkohttpsession/javadsl/JwtSessionSerializers.java create mode 100644 pekko-http-session/jwt/src/main/scala/com/softwaremill/pekkohttpsession/JValueSessionSerializer.scala create mode 100644 pekko-http-session/jwt/src/main/scala/com/softwaremill/pekkohttpsession/JwtSessionEncoder.scala create mode 100644 pekko-http-session/jwt/src/test/scala/com/softwaremill/pekkohttpsession/SessionManagerJwtEncoderTest.scala create mode 100644 pekko-http-session/project/build.properties create mode 100644 pekko-http-session/project/plugins.sbt diff --git a/pekko-http-session/build.sbt b/pekko-http-session/build.sbt new file mode 100644 index 00000000..78685a80 --- /dev/null +++ b/pekko-http-session/build.sbt @@ -0,0 +1,94 @@ +import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings +import com.softwaremill.Publish.ossPublishSettings + +val scala2_12 = "2.12.18" +val scala2_13 = "2.13.11" +val scala3 = "3.3.0" +val scalaVersions = List(scala2_12, scala2_13, scala3) + +lazy val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq( + organization := "com.softwaremill.pekko-http-session", + versionScheme := Some("early-semver") +) + +val pekkoHttpVersion = "1.0.0" +val pekkoStreamsVersion = "1.0.1" +val scalaJava8CompatVersion = "1.0.2" +val json4sVersion = "4.0.4" +val pekkoStreamsProvided = "org.apache.pekko" %% "pekko-stream" % pekkoStreamsVersion % "provided" +val pekkoStreamsTestkit = "org.apache.pekko" %% "pekko-stream-testkit" % pekkoStreamsVersion % "test" + +val scalaTest = "org.scalatest" %% "scalatest" % "3.2.16" % "test" + +lazy val rootProject = (project in file(".")) + .settings(commonSettings: _*) + .settings(publish / skip := true, name := "pekko-http-session", scalaVersion := scala2_13) + .aggregate(core.projectRefs ++ jwt.projectRefs ++ example.projectRefs ++ javaTests.projectRefs: _*) + +lazy val core = (projectMatrix in file("core")) + .settings(commonSettings: _*) + .settings( + name := "core", + libraryDependencies ++= Seq( + "org.apache.pekko" %% "pekko-http" % pekkoHttpVersion, + "org.scala-lang.modules" %% "scala-java8-compat" % scalaJava8CompatVersion, + pekkoStreamsProvided, + "org.apache.pekko" %% "pekko-http-testkit" % pekkoHttpVersion % "test", + pekkoStreamsTestkit, + "org.scalacheck" %% "scalacheck" % "1.15.4" % "test", + scalaTest + ) + ) + .jvmPlatform(scalaVersions = scalaVersions) + +lazy val jwt = (projectMatrix in file("jwt")) + .settings(commonSettings: _*) + .settings( + name := "jwt", + libraryDependencies ++= Seq( + "org.json4s" %% "json4s-jackson" % json4sVersion, + "org.json4s" %% "json4s-ast" % json4sVersion, + "org.json4s" %% "json4s-core" % json4sVersion, + pekkoStreamsProvided, + scalaTest + ), + // generating docs for 2.13 causes an error: "not found: type DefaultFormats$" + Compile / doc / sources := Seq.empty + ) + .jvmPlatform(scalaVersions = scalaVersions) + .dependsOn(core) + +lazy val example = (projectMatrix in file("example")) + .settings(commonSettings: _*) + .settings( + publishArtifact := false, + libraryDependencies ++= Seq( + pekkoStreamsProvided, + "com.typesafe.scala-logging" %% "scala-logging" % "3.9.4", + "ch.qos.logback" % "logback-classic" % "1.2.12", + "org.json4s" %% "json4s-ext" % json4sVersion + ) + ) + .jvmPlatform(scalaVersions = scalaVersions) + .dependsOn(core, jwt) + +lazy val javaTests = (projectMatrix in file("javaTests")) + .settings(commonSettings: _*) + .settings( + name := "javaTests", + Test / testOptions := Seq(Tests.Argument(TestFrameworks.JUnit, "-a")), // required for javadsl JUnit tests + crossPaths := false, // https://github.com/sbt/junit-interface/issues/35 + publishArtifact := false, + libraryDependencies ++= Seq( + pekkoStreamsProvided, + "org.apache.pekko" %% "pekko-http" % pekkoHttpVersion, + "org.scala-lang.modules" %% "scala-java8-compat" % scalaJava8CompatVersion, + "org.apache.pekko" %% "pekko-http-testkit" % pekkoHttpVersion % "test", + pekkoStreamsTestkit, + "junit" % "junit" % "4.13.2" % "test", + "com.github.sbt" % "junit-interface" % "0.13.3" % "test", + scalaTest + ) + ) + .jvmPlatform(scalaVersions = scalaVersions) + .dependsOn(core, jwt) diff --git a/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/CsrfDirectives.scala b/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/CsrfDirectives.scala new file mode 100644 index 00000000..dbbfc73d --- /dev/null +++ b/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/CsrfDirectives.scala @@ -0,0 +1,35 @@ +package com.softwaremill.pekkohttpsession.javadsl + +import com.softwaremill.pekkohttpsession +import com.softwaremill.pekkohttpsession.CsrfCheckMode + +import java.util.function.Supplier +import org.apache.pekko.http.javadsl.server.Route +import org.apache.pekko.http.javadsl.server.directives.RouteAdapter + +/** + * Java alternative for com.softwaremill.pekkohttpsession.CsrfDirectives + */ +trait CsrfDirectives { + + def hmacTokenCsrfProtection[T](checkMode: CsrfCheckMode[T], inner: Supplier[Route]): Route = RouteAdapter { + pekkohttpsession.CsrfDirectives.hmacTokenCsrfProtection(checkMode) { + inner.get.asInstanceOf[RouteAdapter].delegate + } + } + + /** + * @deprecated as of release 0.6.1, replaced by {@link #hmacTokensCsrfProtection()} + */ + def randomTokenCsrfProtection[T](checkMode: CsrfCheckMode[T], inner: Supplier[Route]): Route = + hmacTokenCsrfProtection(checkMode, inner) + + def setNewCsrfToken[T](checkMode: CsrfCheckMode[T], inner: Supplier[Route]): Route = RouteAdapter { + pekkohttpsession.CsrfDirectives.setNewCsrfToken(checkMode) { + inner.get.asInstanceOf[RouteAdapter].delegate + } + } + +} + +object CsrfDirectives extends CsrfDirectives diff --git a/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/HttpSessionAwareDirectives.java b/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/HttpSessionAwareDirectives.java new file mode 100644 index 00000000..e0b2a00e --- /dev/null +++ b/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/HttpSessionAwareDirectives.java @@ -0,0 +1,59 @@ +package com.softwaremill.pekkohttpsession.javadsl; + +import org.apache.pekko.http.javadsl.server.AllDirectives; +import org.apache.pekko.http.javadsl.server.Route; +import com.softwaremill.pekkohttpsession.CsrfCheckMode; +import com.softwaremill.pekkohttpsession.GetSessionTransport; +import com.softwaremill.pekkohttpsession.SessionContinuity; +import com.softwaremill.pekkohttpsession.SessionManager; +import com.softwaremill.pekkohttpsession.SessionResult; +import com.softwaremill.pekkohttpsession.SetSessionTransport; + +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +public class HttpSessionAwareDirectives extends AllDirectives { + + private final SessionManager sessionManager; + + public HttpSessionAwareDirectives(SessionManager sessionManager) { + this.sessionManager = sessionManager; + } + + public Route session(SessionContinuity sc, GetSessionTransport st, Function, Route> continuity) { + return SessionDirectives$.MODULE$.session(sc, st, continuity); + } + + public Route setSession(SessionContinuity sc, SetSessionTransport st, T session, Supplier continuity) { + return SessionDirectives$.MODULE$.setSession(sc, st, session, continuity); + } + + public Route optionalSession(SessionContinuity sc, SetSessionTransport st, Function, Route> continuity) { + return SessionDirectives$.MODULE$.optionalSession(sc, st, continuity); + } + + public Route requiredSession(SessionContinuity sc, SetSessionTransport st, Function continuity) { + return SessionDirectives$.MODULE$.requiredSession(sc, st, continuity); + } + + public Route touchRequiredSession(SessionContinuity sc, SetSessionTransport st, Function continuity) { + return SessionDirectives$.MODULE$.touchRequiredSession(sc, st, continuity); + } + + public Route invalidateSession(SessionContinuity sc, SetSessionTransport st, Supplier continuity) { + return SessionDirectives$.MODULE$.invalidateSession(sc, st, continuity); + } + + public Route setNewCsrfToken(CsrfCheckMode checkHeader, Supplier continuity) { + return CsrfDirectives$.MODULE$.setNewCsrfToken(checkHeader, continuity); + } + + public Route randomTokenCsrfProtection(CsrfCheckMode checkHeader, Supplier continuity) { + return CsrfDirectives$.MODULE$.randomTokenCsrfProtection(checkHeader, continuity); + } + + public SessionManager getSessionManager() { + return sessionManager; + } +} diff --git a/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/InMemoryRefreshTokenStorage.scala b/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/InMemoryRefreshTokenStorage.scala new file mode 100644 index 00000000..dad53f6c --- /dev/null +++ b/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/InMemoryRefreshTokenStorage.scala @@ -0,0 +1,7 @@ +package com.softwaremill.pekkohttpsession.javadsl + +/** + * Can't use the trait com.softwaremill.pekkohttpsession.InMemoryRefreshTokenStorage in Java code, hence this wrapper + * http://stackoverflow.com/questions/7637752/using-scala-traits-with-implemented-methods-in-java + */ +abstract class InMemoryRefreshTokenStorage[T]() extends com.softwaremill.pekkohttpsession.InMemoryRefreshTokenStorage[T] diff --git a/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/SessionDirectives.scala b/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/SessionDirectives.scala new file mode 100644 index 00000000..0e92496a --- /dev/null +++ b/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/SessionDirectives.scala @@ -0,0 +1,57 @@ +package com.softwaremill.pekkohttpsession.javadsl + +import com.softwaremill.pekkohttpsession +import com.softwaremill.pekkohttpsession.{GetSessionTransport, OneOffSessionDirectives, RefreshableSessionDirectives, SessionContinuity, SessionResult, SetSessionTransport} + +import java.util.Optional +import java.util.function.Supplier +import org.apache.pekko.http.javadsl.server.Route +import org.apache.pekko.http.javadsl.server.directives.RouteAdapter + +import scala.compat.java8.OptionConverters._ + +/** + * Java alternative for com.softwaremill.pekkohttpsession.SessionDirectives + */ +trait SessionDirectives extends OneOffSessionDirectives with RefreshableSessionDirectives { + + def session[T](sc: SessionContinuity[T], st: GetSessionTransport, inner: java.util.function.Function[SessionResult[T], Route]): Route = RouteAdapter { + pekkohttpsession.SessionDirectives.session(sc, st) { sessionResult => + inner.apply(sessionResult).asInstanceOf[RouteAdapter].delegate + } + } + + def setSession[T](sc: SessionContinuity[T], st: SetSessionTransport, v: T, inner: Supplier[Route]): Route = RouteAdapter { + pekkohttpsession.SessionDirectives.setSession(sc, st, v) { + inner.get.asInstanceOf[RouteAdapter].delegate + } + } + + def invalidateSession[T](sc: SessionContinuity[T], st: GetSessionTransport, inner: Supplier[Route]): Route = RouteAdapter { + pekkohttpsession.SessionDirectives.invalidateSession(sc, st) { + inner.get.asInstanceOf[RouteAdapter].delegate + } + } + + def optionalSession[T](sc: SessionContinuity[T], st: GetSessionTransport, inner: java.util.function.Function[Optional[T], Route]): Route = RouteAdapter { + pekkohttpsession.SessionDirectives.optionalSession(sc, st) { session => + inner.apply(session.asJava).asInstanceOf[RouteAdapter].delegate + } + } + + def requiredSession[T](sc: SessionContinuity[T], st: GetSessionTransport, inner: java.util.function.Function[T, Route]): Route = RouteAdapter { + pekkohttpsession.SessionDirectives.requiredSession(sc, st) { session => + inner.apply(session).asInstanceOf[RouteAdapter].delegate + } + } + + def touchRequiredSession[T](sc: SessionContinuity[T], st: GetSessionTransport, inner: java.util.function.Function[T, Route]): Route = RouteAdapter { + pekkohttpsession.SessionDirectives.touchRequiredSession(sc, st) { session => + inner.apply(session).asInstanceOf[RouteAdapter].delegate + } + } + +} + +object SessionDirectives extends SessionDirectives + diff --git a/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/SessionSerializers.java b/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/SessionSerializers.java new file mode 100644 index 00000000..8d0a02bc --- /dev/null +++ b/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/SessionSerializers.java @@ -0,0 +1,34 @@ +package com.softwaremill.pekkohttpsession.javadsl; + +import com.softwaremill.pekkohttpsession.MultiValueSessionSerializer; +import com.softwaremill.pekkohttpsession.SessionSerializer; +import com.softwaremill.pekkohttpsession.SessionSerializer$; +import com.softwaremill.pekkohttpsession.converters.MapConverters; +import scala.collection.JavaConverters; +import scala.compat.java8.JFunction0; +import scala.compat.java8.JFunction1; +import scala.util.Try; + +import java.util.Map; + +/** + * Wrapper for session serializers in com.softwaremill.pekkohttpsession.SessionSerializer + */ +public final class SessionSerializers { + + public static final SessionSerializer StringToStringSessionSerializer = SessionSerializer$.MODULE$.stringToStringSessionSerializer(); + public static final SessionSerializer IntToStringSessionSerializer = (SessionSerializer) (SessionSerializer) SessionSerializer$.MODULE$.intToStringSessionSerializer(); + public static final SessionSerializer LongToStringSessionSerializer = (SessionSerializer) (SessionSerializer) SessionSerializer$.MODULE$.longToStringSessionSerializer(); + public static final SessionSerializer FloatToStringSessionSerializer = (SessionSerializer) (SessionSerializer) SessionSerializer$.MODULE$.floatToStringSessionSerializer(); + public static final SessionSerializer DoubleToStringSessionSerializer = (SessionSerializer) (SessionSerializer) SessionSerializer$.MODULE$.doubleToStringSessionSerializer(); + + public static final SessionSerializer, String> MapToStringSessionSerializer = new MultiValueSessionSerializer<>( + (JFunction1, scala.collection.immutable.Map>) m -> MapConverters.toImmutableMap(m), + (JFunction1, Try>>) v1 -> + Try.apply((JFunction0>) () -> JavaConverters.mapAsJavaMapConverter(v1).asJava()) + ); + + private SessionSerializers() { + } + +} diff --git a/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/SessionTransports.java b/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/SessionTransports.java new file mode 100644 index 00000000..df70c54a --- /dev/null +++ b/pekko-http-session/core/src/main/java/com/softwaremill/pekkohttpsession/javadsl/SessionTransports.java @@ -0,0 +1,18 @@ +package com.softwaremill.pekkohttpsession.javadsl; + +import com.softwaremill.pekkohttpsession.CookieST$; +import com.softwaremill.pekkohttpsession.HeaderST$; +import com.softwaremill.pekkohttpsession.SetSessionTransport; + +/** + * Wrapper for session transports in com.softwaremill.pekkohttpsession.SetSessionTransport + */ +public final class SessionTransports { + + public static final SetSessionTransport CookieST = CookieST$.MODULE$; + public static final SetSessionTransport HeaderST = HeaderST$.MODULE$; + + private SessionTransports() { + } + +} diff --git a/pekko-http-session/core/src/main/resources/reference.conf b/pekko-http-session/core/src/main/resources/reference.conf new file mode 100644 index 00000000..b8508279 --- /dev/null +++ b/pekko-http-session/core/src/main/resources/reference.conf @@ -0,0 +1,60 @@ +pekko.http.session { + cookie { + name = "_sessiondata" + domain = none + path = / + secure = false + http-only = true + same-site = Lax + } + header { + send-to-client-name = "Set-Authorization" + get-from-client-name = "Authorization" + } + max-age = 7 days + encrypt-data = false + + jws { + alg = "HS256" + } + + jwt {} + + csrf { + cookie { + name = "XSRF-TOKEN" + domain = none + path = / + secure = false + http-only = false + same-site = Lax + } + submitted-name = "X-XSRF-TOKEN" + } + + refresh-token { + cookie { + name = "_refreshtoken" + domain = none + path = / + secure = false + http-only = true + same-site = Lax + } + header { + send-to-client-name = "Set-Refresh-Token" + get-from-client-name = "Refresh-Token" + } + max-age = 30 days + remove-used-token-after = 5 seconds + } + + token-migration { + v0-5-2 { + enabled = false + } + v0-5-3 { + enabled = false + } + } +} diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/Crypto.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/Crypto.scala new file mode 100644 index 00000000..b09ebf5e --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/Crypto.scala @@ -0,0 +1,45 @@ +package com.softwaremill.pekkohttpsession + +import java.security.MessageDigest +import java.util + +import SessionUtil._ +import javax.crypto.spec.SecretKeySpec +import javax.crypto.{Cipher, Mac} + +object Crypto { + def sign_HmacSHA1_hex(message: String, secret: String): String = { + val key = secret.getBytes("UTF-8") + val mac = Mac.getInstance("HmacSHA1") + mac.init(new SecretKeySpec(key, "HmacSHA1")) + toHexString(mac.doFinal(message.getBytes("utf-8"))) + } + + def sign_HmacSHA256_base64_v0_5_2(message: String, secret: String): String = { + val key = secret.getBytes("UTF-8") + val mac = Mac.getInstance("HmacSHA256") + mac.init(new SecretKeySpec(key, "HmacSHA256")) + SessionUtil.toBase64_v0_5_2(mac.doFinal(message.getBytes("utf-8"))) + } + + def encrypt_AES(value: String, secret: String): String = { + val raw = util.Arrays.copyOf(secret.getBytes("utf-8"), 16) + val skeySpec = new SecretKeySpec(raw, "AES") + val cipher = Cipher.getInstance("AES") + cipher.init(Cipher.ENCRYPT_MODE, skeySpec) + toHexString(cipher.doFinal(value.getBytes("utf-8"))) + } + + def decrypt_AES(value: String, secret: String): String = { + val raw = util.Arrays.copyOf(secret.getBytes("utf-8"), 16) + val skeySpec = new SecretKeySpec(raw, "AES") + val cipher = Cipher.getInstance("AES") + cipher.init(Cipher.DECRYPT_MODE, skeySpec) + new String(cipher.doFinal(hexStringToByte(value))) + } + + def hash_SHA256(value: String): String = { + val digest = MessageDigest.getInstance("SHA-256") + toHexString(digest.digest(value.getBytes("UTF-8"))) + } +} diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/CsrfDirectives.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/CsrfDirectives.scala new file mode 100644 index 00000000..40fbf2a6 --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/CsrfDirectives.scala @@ -0,0 +1,79 @@ +package com.softwaremill.pekkohttpsession + +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.server.{ Directive0, Directive1 } +import org.apache.pekko.stream.Materializer + +trait CsrfDirectives { + + /** + * Protects against CSRF attacks using a double-submit cookie. The cookie will be set on any `GET` request which + * doesn't have the token set in the header. For all other requests, the value of the token from the CSRF cookie must + * match the value in the custom header (or request body, if `checkFormBody` is `true`). + * + * The cookie value is the concatenation of a timestamp and its HMAC hash following the OWASP recommendation for + * CSRF prevention: + * @see OWASP + * + * Note that this scheme can be broken when not all subdomains are protected or not using HTTPS and secure cookies, + * and the token is placed in the request body (not in the header). + * + * See the documentation for more details. + */ + def hmacTokenCsrfProtection[T](checkMode: CsrfCheckMode[T]): Directive0 = { + csrfTokenFromCookie(checkMode).flatMap { + case Some(cookie) => + // if a cookie is already set, we let through all get requests (without setting a new token), or validate + // that the token matches. + get.recover { _ => + submittedCsrfToken(checkMode).flatMap { submitted => + if (submitted == cookie && !cookie.isEmpty && checkMode.csrfManager.validateToken(cookie)) { + pass + } else { + reject(checkMode.csrfManager.tokenInvalidRejection).toDirective[Unit] + } + } + } + case None => + // if a cookie is not set, generating a new one for get requests, rejecting other + (get & setNewCsrfToken(checkMode)).recover(_ => reject(checkMode.csrfManager.tokenInvalidRejection)) + } + } + + @deprecated("use hmacTokenCsrfProtection", "0.6.1") + def randomTokenCsrfProtection[T](checkMode: CsrfCheckMode[T]): Directive0 = hmacTokenCsrfProtection(checkMode) + + def submittedCsrfToken[T](checkMode: CsrfCheckMode[T]): Directive1[String] = { + headerValueByName(checkMode.manager.config.csrfSubmittedName).recover { rejections => + checkMode match { + case _: CheckHeaderAndForm[T] => + formField(checkMode.manager.config.csrfSubmittedName) + case _ => reject(rejections: _*) + } + } + } + + def csrfTokenFromCookie[T](checkMode: CsrfCheckMode[T]): Directive1[Option[String]] = + optionalCookie(checkMode.manager.config.csrfCookieConfig.name).map(_.map(_.value)) + + def setNewCsrfToken[T](checkMode: CsrfCheckMode[T]): Directive0 = + setCookie(checkMode.csrfManager.createCookie()) +} + +object CsrfDirectives extends CsrfDirectives + +sealed trait CsrfCheckMode[T] { + def manager: SessionManager[T] + def csrfManager: CsrfManager[T] = manager.csrfManager +} +class CheckHeader[T] private[pekkohttpsession] (implicit val manager: SessionManager[T]) extends CsrfCheckMode[T] +class CheckHeaderAndForm[T] private[pekkohttpsession] (implicit + val manager: SessionManager[T], + val materializer: Materializer) + extends CsrfCheckMode[T] + +object CsrfOptions { + def checkHeader[T](implicit manager: SessionManager[T]): CheckHeader[T] = new CheckHeader[T]() + def checkHeaderAndForm[T](implicit manager: SessionManager[T], materializer: Materializer): CheckHeaderAndForm[T] = + new CheckHeaderAndForm[T]() +} diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/JwsAlgorithm.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/JwsAlgorithm.scala new file mode 100644 index 00000000..e01864d0 --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/JwsAlgorithm.scala @@ -0,0 +1,76 @@ +package com.softwaremill.pekkohttpsession + +import java.nio.charset.StandardCharsets.UTF_8 +import java.security.spec.PKCS8EncodedKeySpec +import java.security.{KeyFactory, PrivateKey, Signature} +import java.util.Base64 + +import com.typesafe.config.Config +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +import scala.util.{Failure, Success, Try} + +sealed trait JwsAlgorithm { + def value: String + def sign(message: String): String + + protected def encode(bytes: Array[Byte]): String = + Base64.getUrlEncoder.withoutPadding().encodeToString(bytes) +} + +object JwsAlgorithm { + + case class Rsa(privateKey: PrivateKey) extends JwsAlgorithm { + + override val value: String = "RS256" + + override def sign(message: String): String = { + val privateSignature: Signature = Signature.getInstance("SHA256withRSA") + privateSignature.initSign(privateKey) + privateSignature.update(message.getBytes(UTF_8)) + + encode(privateSignature.sign()) + } + } + + object Rsa { + + def fromConfig(jwsConfig: Config): Try[Rsa] = { + + def readKeyFromConf(): Try[String] = { + val configKey = "rsa-private-key" + Option(jwsConfig.hasPath(configKey)) + .filter(identity) + .flatMap(_ => Option(jwsConfig.getString(configKey))) + .filter(_.trim.nonEmpty) + .map(_.replaceAll("\\s", "").replaceAll("-----[^-]+-----", "")) + .map(Success(_)) + .getOrElse(Failure(new IllegalArgumentException( + "pekko.http.session.jws.rsa-private-key must be defined in order to use alg = RS256"))) + } + + readKeyFromConf() + .flatMap { key => + Try { + val keyFactory = KeyFactory.getInstance("RSA") + val privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder.decode(key))) + Rsa(privateKey) + }.recoverWith { + case ex => Failure(new IllegalArgumentException("Invalid RSA private key", ex)) + } + } + } + } + + case class HmacSHA256(serverSecret: String) extends JwsAlgorithm { + override val value: String = "HS256" + override def sign(message: String): String = { + val key = serverSecret.getBytes("UTF-8") + val mac = Mac.getInstance("HmacSHA256") + mac.init(new SecretKeySpec(key, "HmacSHA256")) + encode(mac.doFinal(message.getBytes("utf-8"))) + } + } + +} diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/RefreshTokenStorage.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/RefreshTokenStorage.scala new file mode 100644 index 00000000..5eaa55d3 --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/RefreshTokenStorage.scala @@ -0,0 +1,65 @@ +package com.softwaremill.pekkohttpsession + +import scala.collection.mutable +import scala.concurrent.Future +import scala.concurrent.duration.Duration + +trait RefreshTokenStorage[T] { + def lookup(selector: String): Future[Option[RefreshTokenLookupResult[T]]] + def store(data: RefreshTokenData[T]): Future[Unit] + def remove(selector: String): Future[Unit] + def schedule[S](after: Duration)(op: => Future[S]): Unit +} + +case class RefreshTokenData[T](forSession: T, + selector: String, + tokenHash: String, + /** + * Timestamp + */ + expires: Long) + +case class RefreshTokenLookupResult[T](tokenHash: String, + /** + * Timestamp + */ + expires: Long, + createSession: () => T) + +/** + * Useful for testing. + */ +trait InMemoryRefreshTokenStorage[T] extends RefreshTokenStorage[T] { + case class Store(session: T, tokenHash: String, expires: Long) + private val _store = mutable.Map[String, Store]() + + def store: Map[String, Store] = _store.toMap + + override def lookup(selector: String) = { + Future.successful { + val r = _store.get(selector).map(s => RefreshTokenLookupResult[T](s.tokenHash, s.expires, () => s.session)) + log(s"Looking up token for selector: $selector, found: ${r.isDefined}") + r + } + } + + override def store(data: RefreshTokenData[T]) = { + log( + s"Storing token for selector: ${data.selector}, user: ${data.forSession}, " + + s"expires: ${data.expires}, now: ${System.currentTimeMillis()}") + Future.successful(_store.put(data.selector, Store(data.forSession, data.tokenHash, data.expires))) + } + + override def remove(selector: String) = { + log(s"Removing token for selector: $selector") + Future.successful(_store.remove(selector)) + } + + override def schedule[S](after: Duration)(op: => Future[S]) = { + log("Running scheduled operation immediately") + op + Future.successful(()) + } + + def log(msg: String): Unit +} diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionConfig.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionConfig.scala new file mode 100644 index 00000000..5826aea0 --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionConfig.scala @@ -0,0 +1,172 @@ +package com.softwaremill.pekkohttpsession + +import com.softwaremill.pekkohttpsession.JwsAlgorithm.Rsa + +import java.util.concurrent.TimeUnit +import JwsAlgorithm.HmacSHA256 +import SessionConfig.{JwsConfig, JwtConfig} +import com.typesafe.config.{Config, ConfigFactory, ConfigValueFactory} +import org.apache.pekko.http.scaladsl.model.headers.SameSite + +case class CookieConfig(name: String, domain: Option[String], path: Option[String], secure: Boolean, httpOnly: Boolean, sameSite: Option[SameSite]) + +case class HeaderConfig(sendToClientHeaderName: String, getFromClientHeaderName: String) + +case class SessionConfig( + /** + * Should be different on each environment and **kept secret!**. It's used to sign and encrypt session data. + * This should be a long random string. + */ + serverSecret: String, + jws: JwsConfig, + jwt: JwtConfig, + sessionCookieConfig: CookieConfig, + sessionHeaderConfig: HeaderConfig, + /** + * If you'd like session cookies to expire as well after a period of inactivity, you can optionally include an + * expiration date in the cookie data (expiration will be validated on the server). The expiration date will be + * calculated by adding the given number of seconds to the time at which the session is last updated. + */ + sessionMaxAgeSeconds: Option[Long], + /** + * By default the session data won't be encrypted, only signed with a hash. Set this to true if you'd like the data + * to be encrypted using a symmetrical key. + */ + sessionEncryptData: Boolean, + csrfCookieConfig: CookieConfig, + /** + * Name of the header or form field in which the CSRF token will be submitted. + */ + csrfSubmittedName: String, + refreshTokenCookieConfig: CookieConfig, + refreshTokenHeaderConfig: HeaderConfig, + refreshTokenMaxAgeSeconds: Long, + /** + * When a refresh token is used to log in, a new one is generated. The old one should be deleted with a delay, + * to properly serve concurrent requests using the old token. + */ + removeUsedRefreshTokenAfter: Long, + /** + * Allow migrating tokens created with prior versions of this library. + */ + tokenMigrationV0_5_2Enabled: Boolean, + tokenMigrationV0_5_3Enabled: Boolean) { + require(serverSecret.length >= 64, "Server secret must be at least 64 characters long!") +} + +object SessionConfig { + + case class JwtConfig(issuer: Option[String], subject: Option[String], audience: Option[String], expirationTimeout: Option[Long], notBeforeOffset: Option[Long], includeIssuedAt: Boolean, includeRandomJwtId: Boolean) + + case class JwsConfig(alg: JwsAlgorithm) + + private implicit class PimpedConfig(config: Config) { + val noneValue = "none" + + def getOptionalString(path: String) = + if (config.getAnyRef(path) == noneValue) None + else + Some(config.getString(path)) + def getOptionalLong(path: String) = + if (config.getAnyRef(path) == noneValue) None + else + Some(config.getLong(path)) + def getOptionalDurationSeconds(path: String) = + if (config.getAnyRef(path) == noneValue) None + else + Some(config.getDuration(path, TimeUnit.SECONDS)) + def getStringIfExists(path: String): Option[String] = ifExists(path, config.getString) + def getDurationSecondsIfExists(path: String): Option[Long] = ifExists(path, config.getDuration(_, TimeUnit.SECONDS)) + def getBooleanIfExists(path: String): Option[Boolean] = ifExists(path, config.getBoolean) + def getConfigIfExists(path: String): Option[Config] = ifExists(path, config.getConfig) + + private def ifExists[T](key: String, getter: String => T): Option[T] = + if (config.hasPath(key)) Some(getter(key)) + else None + } + + def fromConfig(config: Config = ConfigFactory.load()): SessionConfig = { + val scopedConfig = config.getConfig("pekko.http.session") + val csrfConfig = scopedConfig.getConfig("csrf") + val refreshTokenConfig = scopedConfig.getConfig("refresh-token") + val tokenMigrationConfig = scopedConfig.getConfig("token-migration") + val sessionMaxAgeSeconds = scopedConfig.getOptionalDurationSeconds("max-age") + SessionConfig( + serverSecret = scopedConfig.getString("server-secret"), + jws = JwsConfig { + val jwsConfig = scopedConfig.getConfig("jws") + jwsConfig.getString("alg").toUpperCase match { + case "HS256" => + HmacSHA256(scopedConfig.getString("server-secret")) + case "RS256" => + Rsa.fromConfig(jwsConfig).get + case oth => + throw new IllegalArgumentException(s"Unsupported JWS alg '$oth'. Supported algorithms are: HS256, RS256") + } + }, + jwt = { + val claimsConfig = scopedConfig.getConfig("jwt") + + JwtConfig( + issuer = claimsConfig.getStringIfExists("iss"), + subject = claimsConfig.getStringIfExists("sub"), + audience = claimsConfig.getStringIfExists("aud"), + expirationTimeout = claimsConfig.getDurationSecondsIfExists("exp-timeout").orElse(sessionMaxAgeSeconds), + notBeforeOffset = claimsConfig.getDurationSecondsIfExists("nbf-offset"), + includeIssuedAt = claimsConfig.getBooleanIfExists("include-iat").getOrElse(false), + includeRandomJwtId = claimsConfig.getBooleanIfExists("include-jti").getOrElse(false)) + }, + sessionCookieConfig = CookieConfig( + name = scopedConfig.getString("cookie.name"), + domain = scopedConfig.getOptionalString("cookie.domain"), + path = scopedConfig.getOptionalString("cookie.path"), + secure = scopedConfig.getBoolean("cookie.secure"), + httpOnly = scopedConfig.getBoolean("cookie.http-only"), + sameSite = scopedConfig.getOptionalString("cookie.same-site").flatMap { SameSite(_) }, + ), + sessionHeaderConfig = HeaderConfig( + sendToClientHeaderName = scopedConfig.getString("header.send-to-client-name"), + getFromClientHeaderName = scopedConfig.getString("header.get-from-client-name") + ), + sessionMaxAgeSeconds = sessionMaxAgeSeconds, + sessionEncryptData = scopedConfig.getBoolean("encrypt-data"), + csrfCookieConfig = CookieConfig( + name = csrfConfig.getString("cookie.name"), + domain = csrfConfig.getOptionalString("cookie.domain"), + path = csrfConfig.getOptionalString("cookie.path"), + secure = csrfConfig.getBoolean("cookie.secure"), + httpOnly = csrfConfig.getBoolean("cookie.http-only"), + sameSite = scopedConfig.getOptionalString("cookie.same-site").flatMap { SameSite(_) }, + ), + csrfSubmittedName = csrfConfig.getString("submitted-name"), + refreshTokenCookieConfig = CookieConfig( + name = refreshTokenConfig.getString("cookie.name"), + domain = refreshTokenConfig.getOptionalString("cookie.domain"), + path = refreshTokenConfig.getOptionalString("cookie.path"), + secure = refreshTokenConfig.getBoolean("cookie.secure"), + httpOnly = refreshTokenConfig.getBoolean("cookie.http-only"), + sameSite = scopedConfig.getOptionalString("cookie.same-site").flatMap { SameSite(_) }, + ), + refreshTokenHeaderConfig = HeaderConfig( + sendToClientHeaderName = refreshTokenConfig.getString("header.send-to-client-name"), + getFromClientHeaderName = refreshTokenConfig.getString("header.get-from-client-name") + ), + refreshTokenMaxAgeSeconds = refreshTokenConfig.getDuration("max-age", TimeUnit.SECONDS), + removeUsedRefreshTokenAfter = refreshTokenConfig.getDuration("remove-used-token-after", TimeUnit.SECONDS), + tokenMigrationV0_5_2Enabled = tokenMigrationConfig.getBoolean("v0-5-2.enabled"), + tokenMigrationV0_5_3Enabled = tokenMigrationConfig.getBoolean("v0-5-3.enabled") + ) + } + + /** + * Creates a default configuration using the given secret. + */ + def default(serverSecret: String): SessionConfig = + fromConfig( + ConfigFactory + .load() + .withValue("pekko.http.session.server-secret", ConfigValueFactory.fromAnyRef(serverSecret))) + + def defaultConfig(serverSecret: String): SessionConfig = + default(serverSecret) // required for javadsl directives, because default is a keyword +} diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionContinuity.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionContinuity.scala new file mode 100644 index 00000000..1d15c696 --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionContinuity.scala @@ -0,0 +1,18 @@ +package com.softwaremill.pekkohttpsession + +import scala.concurrent.ExecutionContext + +sealed trait SessionContinuity[T] { + def manager: SessionManager[T] + def clientSessionManager = manager.clientSessionManager +} + +class OneOff[T] private[pekkohttpsession] (implicit val manager: SessionManager[T]) extends SessionContinuity[T] + +class Refreshable[T] private[pekkohttpsession] (implicit + val manager: SessionManager[T], + val refreshTokenStorage: RefreshTokenStorage[T], + val ec: ExecutionContext) + extends SessionContinuity[T] { + val refreshTokenManager = manager.createRefreshTokenManager(refreshTokenStorage) +} diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionDirectives.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionDirectives.scala new file mode 100644 index 00000000..200aaefc --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionDirectives.scala @@ -0,0 +1,243 @@ +package com.softwaremill.pekkohttpsession + +import org.apache.pekko.http.scaladsl.model.headers.{HttpCookie, `Set-Cookie`} +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.server.{Directive0, Directive1} +import SessionDirectives.respondWithDefaultCookie + +import scala.concurrent.ExecutionContext + +/** + * Manages cookie-based sessions with optional refresh tokens. A refresh token is written to a separate cookie. + */ +trait SessionDirectives extends OneOffSessionDirectives with RefreshableSessionDirectives { + + /** + * Set the session cookie with the session content. The content is signed, optionally encrypted and with + * an optional expiry date. + * + * If refreshable, generates a new token (removing old ones) and stores it in the refresh token cookie. + */ + def setSession[T](sc: SessionContinuity[T], st: SetSessionTransport, v: T): Directive0 = { + sc match { + case _: OneOff[T] => setOneOffSession(sc, st, v) + case r: Refreshable[T] => setRefreshableSession(r, st, v) + } + } + + /** + * Read a session from the session cookie, wrapped in [[SessionResult]] describing the possible + * success/failure outcomes. + * + * If refreshable, tries to create a new session based on the refresh token cookie. + */ + def session[T](sc: SessionContinuity[T], st: GetSessionTransport): Directive1[SessionResult[T]] = { + sc match { + case _: OneOff[T] => oneOffSession(sc, st) + case r: Refreshable[T] => refreshableSession(r, st) + } + } + + /** + * Invalidate the session cookie. + * + * If refreshable, also removes the refresh token cookie and the refresh token token (from the client and token + * store), if present. + * + * Note that you should use `refreshable` if you use refreshable systems even only for some users. + */ + def invalidateSession[T](sc: SessionContinuity[T], st: GetSessionTransport): Directive0 = { + sc match { + case _: OneOff[T] => invalidateOneOffSession(sc, st) + case r: Refreshable[T] => invalidateOneOffSession(sc, st) & invalidateRefreshableSession(r, st) + } + } + + /** + * Read an optional session from the session cookie. + */ + def optionalSession[T](sc: SessionContinuity[T], st: GetSessionTransport): Directive1[Option[T]] = + session(sc, st).map(_.toOption) + + /** + * Read a required session from the session cookie. + */ + def requiredSession[T](sc: SessionContinuity[T], st: GetSessionTransport): Directive1[T] = + optionalSession(sc, st).flatMap { + case None => reject(sc.clientSessionManager.sessionMissingRejection) + case Some(data) => provide(data) + } + + /** + * Sets the session cookie again with the same data. Useful when using the [[SessionConfig.sessionMaxAgeSeconds]] + * option, as it sets the expiry date anew. + */ + def touchOptionalSession[T](sc: SessionContinuity[T], st: GetSessionTransport): Directive1[Option[T]] = { + optionalSession(sc, st).flatMap { d => + d.fold(pass)(s => setOneOffSessionSameTransport(sc, st, s)) & provide(d) + } + } + + /** + * Sets the session cookie again with the same data. Useful when using the [[SessionConfig.sessionMaxAgeSeconds]] + * option, as it sets the expiry date anew. + */ + def touchRequiredSession[T](sc: SessionContinuity[T], st: GetSessionTransport): Directive1[T] = { + requiredSession(sc, st).flatMap { d => + setOneOffSessionSameTransport(sc, st, d) & provide(d) + } + } + +} + +object SessionDirectives extends SessionDirectives { + private[pekkohttpsession] def respondWithDefaultCookie(defaultCookie: HttpCookie) = + mapResponseHeaders { headers => + val cookieExists = headers.exists { + case `Set-Cookie`(c) => c.name == defaultCookie.name + case _ => false + } + if (cookieExists) headers else `Set-Cookie`(defaultCookie) +: headers + } +} + +object SessionOptions { + def oneOff[T](implicit manager: SessionManager[T]): OneOff[T] = new OneOff[T]()(manager) + + def refreshable[T](implicit + manager: SessionManager[T], + refreshTokenStorage: RefreshTokenStorage[T], + ec: ExecutionContext): Refreshable[T] = + new Refreshable[T]()(manager, refreshTokenStorage, ec) + + def usingCookies = CookieST + def usingHeaders = HeaderST + def usingCookiesOrHeaders = CookieOrHeaderST +} + +trait OneOffSessionDirectives { + private[pekkohttpsession] def setOneOffSession[T](sc: SessionContinuity[T], st: SetSessionTransport, v: T): Directive0 = + st match { + // respondWithDefault* directives let us avoid header/cookie duplication when session has already been set because of refreshable sessions. + case CookieST => respondWithDefaultCookie(sc.clientSessionManager.createCookie(v)) + case HeaderST => respondWithDefaultHeader(sc.clientSessionManager.createHeader(v)) + } + + private[pekkohttpsession] def setOneOffSessionSameTransport[T](sc: SessionContinuity[T], + st: GetSessionTransport, + v: T): Directive0 = + read(sc, st).flatMap { + case None => pass + case Some((_, setSt)) => setOneOffSession(sc, setSt, v) + } + + private def readCookie[T](sc: SessionContinuity[T]) = + optionalCookie(sc.manager.config.sessionCookieConfig.name) + .map(_.map(c => (c.value, CookieST: SetSessionTransport))) + private def readHeader[T](sc: SessionContinuity[T]) = + optionalHeaderValueByName(sc.manager.config.sessionHeaderConfig.getFromClientHeaderName) + .map(_.map(h => (h, HeaderST: SetSessionTransport))) + private def read[T](sc: SessionContinuity[T], + st: GetSessionTransport): Directive1[Option[(String, SetSessionTransport)]] = + st match { + case CookieST => readCookie(sc) + case HeaderST => readHeader(sc) + case CookieOrHeaderST => readCookie(sc).flatMap(_.fold(readHeader(sc))(v => provide(Some(v)))) + } + + private[pekkohttpsession] def oneOffSession[T](sc: SessionContinuity[T], + st: GetSessionTransport): Directive1[SessionResult[T]] = { + read(sc, st).flatMap { + case None => provide(SessionResult.NoSession) + case Some((v, setSt)) => { + sc.clientSessionManager.decode(v) match { + case s: SessionResult.DecodedLegacy[T] => + setOneOffSession(sc, setSt, s.session) & provide(s: SessionResult[T]) + case s => provide(s) + } + } + } + } + + private[pekkohttpsession] def invalidateOneOffSession[T](sc: SessionContinuity[T], st: GetSessionTransport): Directive0 = { + readCookie(sc).flatMap { + case None => + readHeader(sc).flatMap { + case None => pass + case Some(_) => respondWithHeader(sc.clientSessionManager.createHeaderWithValue("")) + } + + case Some(_) => deleteCookie(sc.clientSessionManager.createCookieWithValue("")) + } + } +} + +trait RefreshableSessionDirectives { this: OneOffSessionDirectives => + private[pekkohttpsession] def setRefreshableSession[T](sc: Refreshable[T], st: SetSessionTransport, v: T): Directive0 = { + setOneOffSession(sc, st, v) & setRefreshToken(sc, st, v) + } + + private def readCookie[T](sc: SessionContinuity[T]) = + optionalCookie(sc.manager.config.refreshTokenCookieConfig.name) + .map(_.map(c => (c.value, CookieST: SetSessionTransport))) + private def readHeader[T](sc: SessionContinuity[T]) = + optionalHeaderValueByName(sc.manager.config.refreshTokenHeaderConfig.getFromClientHeaderName) + .map(_.map(h => (h, HeaderST: SetSessionTransport))) + private def read[T](sc: SessionContinuity[T], + st: GetSessionTransport): Directive1[Option[(String, SetSessionTransport)]] = + st match { + case CookieST => readCookie(sc) + case HeaderST => readHeader(sc) + case CookieOrHeaderST => readCookie(sc).flatMap(_.fold(readHeader(sc))(v => provide(Some(v)))) + } + + private[pekkohttpsession] def refreshableSession[T](sc: Refreshable[T], + st: GetSessionTransport): Directive1[SessionResult[T]] = { + import sc.ec + oneOffSession(sc, st).flatMap { + case SessionResult.NoSession | SessionResult.Expired => + read(sc, st).flatMap { + case None => provide(SessionResult.NoSession) + case Some((v, setSt)) => + onSuccess(sc.refreshTokenManager.sessionFromValue(v)) + .flatMap { + case s @ SessionResult.CreatedFromToken(session) => + setRefreshableSession(sc, setSt, session) & provide(s: SessionResult[T]) + case s => provide(s) + } + } + case s => provide(s) + } + } + + private[pekkohttpsession] def invalidateRefreshableSession[T](sc: Refreshable[T], st: GetSessionTransport): Directive0 = { + read(sc, st).flatMap { + case None => pass + case Some((v, setSt)) => + val deleteTokenOnClient = setSt match { + case CookieST => deleteCookie(sc.refreshTokenManager.createCookie("", maxAge = None)) + case HeaderST => respondWithHeader(sc.refreshTokenManager.createHeader("")) + } + + deleteTokenOnClient & + onSuccess(sc.refreshTokenManager.removeToken(v)) + } + } + + private def setRefreshToken[T](sc: Refreshable[T], st: SetSessionTransport, v: T): Directive0 = { + import sc.ec + read(sc, st).flatMap { existing => + val newToken = sc.refreshTokenManager.rotateToken(v, existing.map(_._1)) + + st match { + // respondWithDefault* directives let us avoid header/cookie duplication when session has already been set because of refreshable sessions. + case CookieST => + val createCookie = newToken.map(sc.refreshTokenManager.createCookie(_)) + onSuccess(createCookie).flatMap(c => respondWithDefaultCookie(c)) + case HeaderST => + val createHeader = newToken.map(sc.refreshTokenManager.createHeader) + onSuccess(createHeader).flatMap(c => respondWithDefaultHeader(c)) + } + } + } +} diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionEncoder.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionEncoder.scala new file mode 100644 index 00000000..d88bd4d3 --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionEncoder.scala @@ -0,0 +1,85 @@ +package com.softwaremill.pekkohttpsession + +import scala.util.Try + +trait SessionEncoder[T] { + def encode(t: T, nowMillis: Long, config: SessionConfig): String + def decode(s: String, config: SessionConfig): Try[DecodeResult[T]] +} + +object SessionEncoder { + + /** + * Default low-priority implicit encoder. If you wish to use another one, provide an implicit encoder in a + * higher-priority implicit scope, e.g. as an implicit value declared next to `SessionManager`. + */ + implicit def basic[T](implicit serializer: SessionSerializer[T, String]): BasicSessionEncoder[T] = + new BasicSessionEncoder[T]() +} + +case class DecodeResult[T](t: T, expires: Option[Long], signatureMatches: Boolean, isLegacy: Boolean) + +/** + * @param serializer Must create cookie-safe strings (only with allowed characters). + */ +class BasicSessionEncoder[T](implicit serializer: SessionSerializer[T, String]) extends SessionEncoder[T] { + + override def encode(t: T, nowMillis: Long, config: SessionConfig) = { + // adding an "x" so that the string is never empty, even if there's no data + val serialized = "x" + serializer.serialize(t) + + val withExpiry = config.sessionMaxAgeSeconds.fold(serialized) { maxAge => + val expiry = nowMillis + maxAge * 1000L + s"$expiry-$serialized" + } + + val encrypted = if (config.sessionEncryptData) Crypto.encrypt_AES(withExpiry, config.serverSecret) else withExpiry + + s"${Crypto.sign_HmacSHA1_hex(withExpiry, config.serverSecret)}-$encrypted" + } + + override def decode(s: String, config: SessionConfig) = { + def extractExpiry(data: String): (Option[Long], String) = { + config.sessionMaxAgeSeconds.fold((Option.empty[Long], data)) { _ => + val splitted = data.split("-", 2) + (Some(splitted(0).toLong), splitted(1)) + } + } + + def verifySignature(tokenSignature: String, expectedValue: String) = { + SessionUtil.constantTimeEquals(tokenSignature, Crypto.sign_HmacSHA1_hex(expectedValue, config.serverSecret)) + } + + Try { + val splitted = s.split("-", 2) + val decrypted = + if (config.sessionEncryptData) Crypto.decrypt_AES(splitted(1), config.serverSecret) else splitted(1) + val (expiry, serialized) = extractExpiry(decrypted) + + val (deserializedResult, deserializedLegacy) = { + val deserializedResult = serializer.deserialize(serialized.substring(1)) + + if (deserializedResult.isFailure && config.tokenMigrationV0_5_3Enabled) { + // Try deserializer assuming pre-v0.5.3. + (serializer.deserializeV0_5_2(serialized.substring(1)), true) + } else { + (deserializedResult, false) + } + } + + deserializedResult.map { deserialized => + val signatureMatches = verifySignature(splitted(0), decrypted) + + if (!signatureMatches && config.tokenMigrationV0_5_2Enabled) { + // Try signature check assuming pre-v0.5.2. + val signatureMatchesLegacy = verifySignature(splitted(0), serialized) + val isLegacy = signatureMatchesLegacy || deserializedLegacy + + DecodeResult(deserialized, expiry, signatureMatchesLegacy, isLegacy) + } else { + DecodeResult(deserialized, expiry, signatureMatches, isLegacy = deserializedLegacy) + } + } + }.flatten + } +} diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionManager.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionManager.scala new file mode 100644 index 00000000..ed3212a0 --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionManager.scala @@ -0,0 +1,226 @@ +package com.softwaremill.pekkohttpsession + +import java.util.concurrent.TimeUnit +import org.apache.pekko.http.scaladsl.server.AuthorizationFailedRejection + +import scala.concurrent.duration.Duration +import scala.concurrent.{ExecutionContext, Future} + +import org.apache.pekko.http.scaladsl.model.headers.{RawHeader, HttpCookie} + +import scala.util.Try + +class SessionManager[T](val config: SessionConfig)(implicit sessionEncoder: SessionEncoder[T]) { manager => + + val clientSessionManager: ClientSessionManager[T] = new ClientSessionManager[T] { + override def config = manager.config + override def sessionEncoder = manager.sessionEncoder + override def nowMillis = manager.nowMillis + } + + val csrfManager: CsrfManager[T] = new CsrfManager[T] { + override def config = manager.config + override def nowMillis = manager.nowMillis + } + + def createRefreshTokenManager(_storage: RefreshTokenStorage[T]): RefreshTokenManager[T] = new RefreshTokenManager[T] { + override def config = manager.config + override def nowMillis = manager.nowMillis + override def storage = _storage + } + + def nowMillis = System.currentTimeMillis() +} + +// Partially based on the implementation from Play! [[https://github.com/playframework]] +// see https://github.com/playframework/playframework/blob/master/framework/src/play/src/main/scala/play/api/mvc/Http.scala +trait ClientSessionManager[T] { + def config: SessionConfig + def sessionEncoder: SessionEncoder[T] + def nowMillis: Long + + def createCookie(data: T) = createCookieWithValue(encode(data)) + + def createCookieWithValue(value: String) = + HttpCookie( + name = config.sessionCookieConfig.name, + value = value, + expires = None, + maxAge = None, + domain = config.sessionCookieConfig.domain, + path = config.sessionCookieConfig.path, + secure = config.sessionCookieConfig.secure, + httpOnly = config.sessionCookieConfig.httpOnly, + ).withSameSite(config.sessionCookieConfig.sameSite) + + def createHeader(data: T) = createHeaderWithValue(encode(data)) + + def createHeaderWithValue(value: String) = + RawHeader(name = config.sessionHeaderConfig.sendToClientHeaderName, value = value) + + def encode(data: T): String = sessionEncoder.encode(data, nowMillis, config) + + def decode(data: String): SessionResult[T] = { + sessionEncoder + .decode(data, config) + .map { dr => + val expired = config.sessionMaxAgeSeconds.fold(false)(_ => nowMillis > dr.expires.getOrElse(Long.MaxValue)) + if (expired) { + SessionResult.Expired + } else if (!dr.signatureMatches) { + SessionResult.Corrupt(new RuntimeException("Corrupt signature")) + } else if (dr.isLegacy) { + SessionResult.DecodedLegacy(dr.t) + } else { + SessionResult.Decoded(dr.t) + } + } + .recover { case t: Exception => SessionResult.Corrupt(t) } + .get + } + + def sessionMissingRejection = AuthorizationFailedRejection +} + +trait CsrfManager[T] { + def config: SessionConfig + def nowMillis: Long + + def tokenInvalidRejection = AuthorizationFailedRejection + + def createToken(): String = { + val millis = nowMillis.toString + val hmac = generateHmac(millis) + encodeToken(millis, hmac) + } + def validateToken(token: String): Boolean = + token.nonEmpty && + decodeToken(token).fold( + _ => false, + { case (millis, hmac) => SessionUtil.constantTimeEquals(hmac, generateHmac(millis)) } + ) + private def encodeToken(millis: String, hmac: String): String = s"$millis-$hmac" + private def decodeToken(token: String): Try[(String, String)] = Try { + val splitted = token.split("-", 2) + (splitted(0), splitted(1)) + } + private def generateHmac(t: String): String = Crypto.sign_HmacSHA256_base64_v0_5_2(t, config.serverSecret) + + def createCookie() = + HttpCookie( + name = config.csrfCookieConfig.name, + value = createToken(), + expires = None, + domain = config.csrfCookieConfig.domain, + path = config.csrfCookieConfig.path, + secure = config.csrfCookieConfig.secure, + httpOnly = config.csrfCookieConfig.httpOnly, + ).withSameSite(config.csrfCookieConfig.sameSite) +} + +trait RefreshTokenManager[T] { + def config: SessionConfig + def nowMillis: Long + def storage: RefreshTokenStorage[T] + + def createSelector(): String = SessionUtil.randomString(16) + def createToken(): String = SessionUtil.randomString(64) + + def decodeSelectorAndToken(value: String): Option[(String, String)] = { + val s = value.split(":", 2) + if (s.length == 2) Some((s(0), s(1))) else None + } + + def encodeSelectorAndToken(selector: String, token: String): String = s"$selector:$token" + + /** + * Creates and stores a new token, removing the old one after a configured period of time, if it exists. + */ + def rotateToken(session: T, existing: Option[String])(implicit ec: ExecutionContext): Future[String] = { + + val selector = createSelector() + val token = createToken() + + val storeFuture = storage + .store( + new RefreshTokenData[T](forSession = session, + selector = selector, + tokenHash = Crypto.hash_SHA256(token), + expires = nowMillis + config.refreshTokenMaxAgeSeconds * 1000L)) + .map(_ => encodeSelectorAndToken(selector, token)) + + existing.flatMap(decodeSelectorAndToken).foreach { + case (s, _) => + storage.schedule(Duration(config.removeUsedRefreshTokenAfter, TimeUnit.SECONDS)) { + storage.remove(s) + } + } + + storeFuture + } + + def createCookie(value: String, maxAge: Option[Long] = Some(config.refreshTokenMaxAgeSeconds)) = + HttpCookie( + name = config.refreshTokenCookieConfig.name, + value = value, + expires = None, + maxAge = maxAge, + domain = config.refreshTokenCookieConfig.domain, + path = config.refreshTokenCookieConfig.path, + secure = config.refreshTokenCookieConfig.secure, + httpOnly = config.refreshTokenCookieConfig.httpOnly, + ).withSameSite(config.refreshTokenCookieConfig.sameSite) + + def createHeader(value: String) = + RawHeader(name = config.refreshTokenHeaderConfig.sendToClientHeaderName, value = value) + + def sessionFromValue(value: String)(implicit ec: ExecutionContext): Future[SessionResult[T]] = { + decodeSelectorAndToken(value) match { + case Some((selector, token)) => + storage.lookup(selector).flatMap { + case Some(lookupResult) => + if (lookupResult.expires < nowMillis) { + storage.remove(selector).map(_ => SessionResult.Expired) + } else if (!SessionUtil.constantTimeEquals(Crypto.hash_SHA256(token), lookupResult.tokenHash)) { + storage.remove(selector).map(_ => SessionResult.Corrupt(new RuntimeException("Corrupt token hash"))) + } else { + Future.successful(SessionResult.CreatedFromToken(lookupResult.createSession())) + } + + case None => + Future.successful(SessionResult.TokenNotFound) + } + case None => Future.successful(SessionResult.Corrupt(new RuntimeException("Cannot decode selector/token"))) + } + } + + def removeToken(value: String): Future[Unit] = { + decodeSelectorAndToken(value) match { + case Some((s, _)) => storage.remove(s) + case None => Future.successful(()) + } + } +} + +sealed trait SessionResult[+T] { + def toOption: Option[T] +} + +object SessionResult { + sealed trait SessionValue[T] extends SessionResult[T] { + def session: T + def toOption: Option[T] = Some(session) + } + sealed trait NoSessionValue[T] extends SessionResult[T] { + def toOption: Option[T] = None + } + + case class Decoded[T](session: T) extends SessionResult[T] with SessionValue[T] + case class DecodedLegacy[T](session: T) extends SessionResult[T] with SessionValue[T] + case class CreatedFromToken[T](session: T) extends SessionResult[T] with SessionValue[T] + + case object NoSession extends SessionResult[Nothing] with NoSessionValue[Nothing] + case object TokenNotFound extends SessionResult[Nothing] with NoSessionValue[Nothing] + case object Expired extends SessionResult[Nothing] with NoSessionValue[Nothing] + case class Corrupt(e: Exception) extends SessionResult[Nothing] with NoSessionValue[Nothing] +} diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionSerializer.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionSerializer.scala new file mode 100644 index 00000000..d37bedd0 --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionSerializer.scala @@ -0,0 +1,92 @@ +package com.softwaremill.pekkohttpsession + +import java.net.{URLDecoder, URLEncoder} + +import scala.util.Try + +trait SessionSerializer[T, R] { + def serialize(t: T): R + def deserialize(r: R): Try[T] + + def deserializeV0_5_2(r: R): Try[T] = deserialize(r) +} + +class SingleValueSessionSerializer[T, V](toValue: T => V, fromValue: V => Try[T])( + implicit + valueSerializer: SessionSerializer[V, String]) + extends SessionSerializer[T, String] { + + override def serialize(t: T) = valueSerializer.serialize(toValue(t)) + + override def deserialize(r: String) = valueSerializer.deserialize(r).flatMap(fromValue) +} + +class MultiValueSessionSerializer[T](toMap: T => Map[String, String], fromMap: Map[String, String] => Try[T]) + extends SessionSerializer[T, String] { + + import SessionSerializer._ + + override def serialize(t: T) = + toMap(t) + .map { case (k, v) => urlEncode(k) + "~" + urlEncode(v) } + .mkString("&") + + override def deserialize(s: String) = { + Try { + if (s == "") Map.empty[String, String] + else { + s.split("&") + .map(_.split("~", 2)) + .map(p => urlDecode(p(0)) -> urlDecode(p(1))) + .toMap + } + }.flatMap(fromMap) + } + + override def deserializeV0_5_2(s: String) = { + Try { + if (s == "") Map.empty[String, String] + else { + s.split("&") + .map(_.split("=", 2)) + .map(p => urlDecode(p(0)) -> urlDecode(p(1))) + .toMap + } + }.flatMap(fromMap) + } +} + +object SessionSerializer { + implicit def stringToStringSessionSerializer: SessionSerializer[String, String] = + new SessionSerializer[String, String] { + override def serialize(t: String) = urlEncode(t) + override def deserialize(s: String) = Try(urlDecode(s)) + } + + implicit def intToStringSessionSerializer: SessionSerializer[Int, String] = new SessionSerializer[Int, String] { + override def serialize(t: Int) = urlEncode(t.toString) + override def deserialize(s: String) = Try(urlDecode(s).toInt) + } + + implicit def longToStringSessionSerializer: SessionSerializer[Long, String] = new SessionSerializer[Long, String] { + override def serialize(t: Long) = urlEncode(t.toString) + override def deserialize(s: String) = Try(urlDecode(s).toLong) + } + + implicit def floatToStringSessionSerializer: SessionSerializer[Float, String] = new SessionSerializer[Float, String] { + override def serialize(t: Float) = urlEncode(t.toString) + override def deserialize(s: String) = Try(urlDecode(s).toFloat) + } + + implicit def doubleToStringSessionSerializer: SessionSerializer[Double, String] = + new SessionSerializer[Double, String] { + override def serialize(t: Double) = urlEncode(t.toString) + override def deserialize(s: String) = Try(urlDecode(s).toDouble) + } + + implicit def mapToStringSessionSerializer: SessionSerializer[Map[String, String], String] = + new MultiValueSessionSerializer[Map[String, String]](identity, Try(_)) + + private[pekkohttpsession] def urlEncode(s: String): String = URLEncoder.encode(s, "UTF-8") + private[pekkohttpsession] def urlDecode(s: String): String = URLDecoder.decode(s, "UTF-8") +} diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionTransport.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionTransport.scala new file mode 100644 index 00000000..4956a244 --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionTransport.scala @@ -0,0 +1,7 @@ +package com.softwaremill.pekkohttpsession + +sealed trait GetSessionTransport +sealed trait SetSessionTransport extends GetSessionTransport +case object CookieST extends SetSessionTransport +case object HeaderST extends SetSessionTransport +case object CookieOrHeaderST extends GetSessionTransport diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionUtil.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionUtil.scala new file mode 100644 index 00000000..1417cd75 --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/SessionUtil.scala @@ -0,0 +1,70 @@ +package com.softwaremill.pekkohttpsession + +import java.math.BigInteger +import java.util.Base64 +import java.util.concurrent.ThreadLocalRandom + +object SessionUtil { + def randomString(length: Int): String = { + // http://stackoverflow.com/questions/41107/how-to-generate-a-random-alpha-numeric-string + val random = ThreadLocalRandom.current() + new BigInteger(length * 5, random).toString(32) // because 2^5 = 32 + } + + /** + * Utility method for generating a good server secret. + */ + def randomServerSecret(): String = randomString(128) + + // Do not change this unless you understand the security issues behind timing attacks. + // This method intentionally runs in constant time if the two strings have the same length. + // If it didn't, it would be vulnerable to a timing attack. + def constantTimeEquals(a: String, b: String): Boolean = { + if (a.length != b.length) { + false + } else { + var equal = 0 + for (i <- Array.range(0, a.length)) { + equal |= a(i) ^ b(i) + } + equal == 0 + } + } + + private val HexArray = "0123456789ABCDEF".toCharArray + + def toHexString(bytes: Array[Byte]): String = { + // from https://stackoverflow.com/questions/9655181/how-to-convert-a-byte-array-to-a-hex-string-in-java + val hexChars = new Array[Char](bytes.length * 2) + var j = 0 + while (j < bytes.length) { + val v = bytes(j) & 0xFF + hexChars(j * 2) = HexArray(v >>> 4) + hexChars(j * 2 + 1) = HexArray(v & 0x0F) + j += 1 + } + new String(hexChars) + } + + def hexStringToByte(hexString: String): Array[Byte] = { + // https://stackoverflow.com/questions/140131/convert-a-string-representation-of-a-hex-dump-to-a-byte-array-using-java + val len = hexString.length + val data = new Array[Byte](len / 2) + var i = 0 + while (i < len) { + data(i / 2) = ((Character.digit(hexString.charAt(i), 16) << 4) + + Character.digit(hexString.charAt(i + 1), 16)).toByte + i += 2 + } + + data + } + + def toBase64_v0_5_2(bytes: Array[Byte]): String = { + Base64.getUrlEncoder.encodeToString(bytes) + } + + def parseBase64_v0_5_2(s: String): Array[Byte] = { + Base64.getUrlDecoder.decode(s) + } +} diff --git a/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/converters/MapConverters.scala b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/converters/MapConverters.scala new file mode 100644 index 00000000..61d70e7d --- /dev/null +++ b/pekko-http-session/core/src/main/scala/com/softwaremill/pekkohttpsession/converters/MapConverters.scala @@ -0,0 +1,9 @@ +package com.softwaremill.pekkohttpsession.converters + +import scala.collection.JavaConverters._ + +object MapConverters { + + implicit def toImmutableMap[A, B](m: java.util.Map[A, B]): scala.collection.immutable.Map[A, B] = m.asScala.toMap + +} diff --git a/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/CsrfDirectivesTest.scala b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/CsrfDirectivesTest.scala new file mode 100644 index 00000000..76f78c3d --- /dev/null +++ b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/CsrfDirectivesTest.scala @@ -0,0 +1,176 @@ +package com.softwaremill.pekkohttpsession + +import org.apache.pekko.http.scaladsl.model.FormData +import org.apache.pekko.http.scaladsl.model.headers.{Cookie, `Set-Cookie`} +import org.apache.pekko.http.scaladsl.server.AuthorizationFailedRejection +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest +import CsrfDirectives._ +import CsrfOptions._ +import org.scalatest._ +import matchers.should._ +import org.scalatest.flatspec.AnyFlatSpec + +class CsrfDirectivesTest extends AnyFlatSpec with ScalatestRouteTest with Matchers { + + import TestData._ + val cookieName = sessionConfig.csrfCookieConfig.name + implicit val csrfCheckMode: CheckHeader[Map[String, String]] = checkHeader + + def routes[T](implicit manager: SessionManager[T], checkMode: CsrfCheckMode[T]) = + hmacTokenCsrfProtection(checkMode) { + get { + path("site") { + complete { + "ok" + } + } + } ~ + post { + path("login") { + setNewCsrfToken(checkMode) { + complete { "ok" } + } + } ~ + path("transfer_money") { + complete { "ok" } + } + } + } + + it should "set the csrf cookie on the first get request only" in { + Get("/site") ~> routes ~> check { + responseAs[String] should be("ok") + + val csrfCookieOption = header[`Set-Cookie`] + csrfCookieOption shouldBe defined + val Some(csrfCookie) = csrfCookieOption + + csrfCookie.cookie.name should be(cookieName) + + Get("/site") ~> addHeader(Cookie(cookieName, csrfCookie.cookie.value)) ~> routes ~> check { + responseAs[String] should be("ok") + + header[`Set-Cookie`] should be(None) + } + } + } + + it should "reject requests if the csrf cookie doesn't match the header value" in { + Get("/site") ~> routes ~> check { + responseAs[String] should be("ok") + val Some(csrfCookie) = header[`Set-Cookie`] + + Post("/transfer_money") ~> + addHeader(Cookie(cookieName, csrfCookie.cookie.value)) ~> + addHeader(sessionConfig.csrfSubmittedName, "something else") ~> + routes ~> + check { + rejections should be(List(AuthorizationFailedRejection)) + } + } + } + + it should "reject requests if the csrf cookie isn't set" in { + Get("/site") ~> routes ~> check { + responseAs[String] should be("ok") + + Post("/transfer_money") ~> + routes ~> + check { + rejections should be(List(AuthorizationFailedRejection)) + } + } + } + + it should "reject requests if the csrf cookie and the header are empty" in { + Get("/site") ~> routes ~> check { + responseAs[String] should be("ok") + + Post("/transfer_money") ~> + addHeader(Cookie(cookieName, "")) ~> + addHeader(sessionConfig.csrfSubmittedName, "") ~> + routes ~> + check { + rejections should be(List(AuthorizationFailedRejection)) + } + } + } + + it should "reject requests if the csrf cookie and the header contain illegal value" in { + Get("/site") ~> routes ~> check { + responseAs[String] should be("ok") + + Post("/transfer_money") ~> + addHeader(Cookie(cookieName, "x")) ~> + addHeader(sessionConfig.csrfSubmittedName, "x") ~> + routes ~> + check { + rejections should be(List(AuthorizationFailedRejection)) + } + } + } + + it should "reject requests if the csrf cookie and the header contain structurally correct but incorrectly hashed value" in { + Get("/site") ~> routes ~> check { + responseAs[String] should be("ok") + + val wrong = s"wrong${System.currentTimeMillis()}" + Post("/transfer_money") ~> + addHeader(Cookie(cookieName, wrong)) ~> + addHeader(sessionConfig.csrfSubmittedName, wrong) ~> + routes ~> + check { + rejections should be(List(AuthorizationFailedRejection)) + } + } + } + + it should "accept requests if the csrf cookie matches the header value" in { + Get("/site") ~> routes ~> check { + responseAs[String] should be("ok") + val Some(csrfCookie) = header[`Set-Cookie`] + + Post("/transfer_money") ~> + addHeader(Cookie(cookieName, csrfCookie.cookie.value)) ~> + addHeader(sessionConfig.csrfSubmittedName, csrfCookie.cookie.value) ~> + routes ~> + check { + responseAs[String] should be("ok") + } + } + } + + it should "accept requests if the csrf cookie matches the form field value" in { + val testRoutes = routes(manager, checkHeaderAndForm) + Get("/site") ~> testRoutes ~> check { + responseAs[String] should be("ok") + val Some(csrfCookie) = header[`Set-Cookie`] + + Post("/transfer_money", FormData(sessionConfig.csrfSubmittedName -> csrfCookie.cookie.value)) ~> + addHeader(Cookie(cookieName, csrfCookie.cookie.value)) ~> + testRoutes ~> + check { + responseAs[String] should be("ok") + } + } + } + + it should "set a new csrf cookie when requested" in { + Get("/site") ~> routes ~> check { + responseAs[String] should be("ok") + val Some(csrfCookie1) = header[`Set-Cookie`] + + Post("/login") ~> + addHeader(Cookie(cookieName, csrfCookie1.cookie.value)) ~> + addHeader(sessionConfig.csrfSubmittedName, csrfCookie1.cookie.value) ~> + routes ~> + check { + responseAs[String] should be("ok") + val Some(csrfCookie2) = header[`Set-Cookie`] + + csrfCookie1.cookie.value should not be (csrfCookie2.cookie.value) + } + } + } +} diff --git a/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/Legacy.scala b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/Legacy.scala new file mode 100644 index 00000000..3252ad47 --- /dev/null +++ b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/Legacy.scala @@ -0,0 +1,48 @@ +package com.softwaremill.pekkohttpsession + +import scala.util.Try + +object Legacy { + class MultiValueSessionSerializerV0_5_2[T](toMap: T => Map[String, String], fromMap: Map[String, String] => Try[T]) + extends SessionSerializer[T, String] { + + import SessionSerializer._ + + override def serialize(t: T) = + toMap(t) + .map { case (k, v) => urlEncode(k) + "=" + urlEncode(v) } + .mkString("&") + + override def deserialize(s: String) = { + Try { + if (s == "") Map.empty[String, String] + else { + s.split("&") + .map(_.split("=", 2)) + .map(p => urlDecode(p(0)) -> urlDecode(p(1))) + .toMap + } + }.flatMap(fromMap) + } + } + + def encodeV0_5_1(data: Map[String, String], nowMillis: Long, config: SessionConfig): String = { + val serializer = new MultiValueSessionSerializerV0_5_2[Map[String, String]](identity, Try(_)) + val serialized = "x" + serializer.serialize(data) + + val withExpiry = config.sessionMaxAgeSeconds.fold(serialized) { maxAge => + val expiry = nowMillis + maxAge * 1000L + s"$expiry-$serialized" + } + + val encrypted = if (config.sessionEncryptData) Crypto.encrypt_AES(withExpiry, config.serverSecret) else withExpiry + + s"${Crypto.sign_HmacSHA1_hex(serialized, config.serverSecret)}-$encrypted" + } + + def encodeV0_5_2(data: Map[String, String], nowMillis: Long, config: SessionConfig): String = { + val serializer = new MultiValueSessionSerializerV0_5_2[Map[String, String]](identity, Try(_)) + val encoder = new BasicSessionEncoder[Map[String, String]]()(serializer) + encoder.encode(data, nowMillis, config) + } +} diff --git a/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/MultipleTransportTest.scala b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/MultipleTransportTest.scala new file mode 100644 index 00000000..c6f30a4a --- /dev/null +++ b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/MultipleTransportTest.scala @@ -0,0 +1,78 @@ +package com.softwaremill.pekkohttpsession + +import org.apache.pekko.http.scaladsl.model.{DateTime, HttpHeader} +import org.apache.pekko.http.scaladsl.model.headers.{RawHeader, HttpCookie, Cookie, `Set-Cookie`} +import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest +import SessionOptions._ +import TestData._ + +trait MultipleTransportTest { this: ScalatestRouteTest => + + trait TestUsingTransport { + def transportName: String + + def getSession: Option[String] + def countSessionHeaders: Int + def setSessionHeader(s: String): HttpHeader + def isSessionExpired: Boolean + + def getRefreshToken: Option[String] + def countRefreshTokenHeaders: Int + def setRefreshTokenHeader(s: String): HttpHeader + def isRefreshTokenExpired: Boolean + + def getSessionTransport: GetSessionTransport + def setSessionTransport: SetSessionTransport + } + + object TestUsingCookies extends TestUsingTransport { + val sessionCookieName = sessionConfig.sessionCookieConfig.name + val refreshTokenCookieName = sessionConfig.refreshTokenCookieConfig.name + + val transportName = "cookies" + + def cookiesMap: Map[String, HttpCookie] = + headers.collect { case `Set-Cookie`(cookie) => cookie.name -> cookie }.toMap + private def countCookies(name: String): Int = headers.count { + case `Set-Cookie`(cookie) => cookie.name == name + case _ => false + } + + def getSession = cookiesMap.get(sessionCookieName).map(_.value) + def countSessionHeaders = countCookies(sessionCookieName) + def setSessionHeader(s: String) = Cookie(sessionCookieName, s) + def isSessionExpired = cookiesMap.get(sessionCookieName).flatMap(_.expires).contains(DateTime.MinValue) + + def getRefreshToken = cookiesMap.get(refreshTokenCookieName).map(_.value) + def countRefreshTokenHeaders = countCookies(refreshTokenCookieName) + def setRefreshTokenHeader(s: String) = Cookie(refreshTokenCookieName, s) + def isRefreshTokenExpired = cookiesMap.get(refreshTokenCookieName).flatMap(_.expires).contains(DateTime.MinValue) + + def getSessionTransport = usingCookies + def setSessionTransport = usingCookies + } + + object TestUsingHeaders extends TestUsingTransport { + val setSessionHeaderName = sessionConfig.sessionHeaderConfig.sendToClientHeaderName + val sessionHeaderName = sessionConfig.sessionHeaderConfig.getFromClientHeaderName + val setRefreshTokenHeaderName = sessionConfig.refreshTokenHeaderConfig.sendToClientHeaderName + val refreshTokenHeaderName = sessionConfig.refreshTokenHeaderConfig.getFromClientHeaderName + + val transportName = "headers" + + private def countHeaders(name: String): Int = headers.count(_.is(name.toLowerCase)) + + def getSession = header(setSessionHeaderName).map(_.value) + def countSessionHeaders = countHeaders(setSessionHeaderName) + def setSessionHeader(s: String) = RawHeader(sessionHeaderName, s) + def isSessionExpired = getSession.contains("") + + def getRefreshToken = header(setRefreshTokenHeaderName).map(_.value) + def countRefreshTokenHeaders = countHeaders(setRefreshTokenHeaderName) + def setRefreshTokenHeader(s: String) = RawHeader(refreshTokenHeaderName, s) + def isRefreshTokenExpired = getRefreshToken.contains("") + + def getSessionTransport = usingHeaders + def setSessionTransport = usingHeaders + } +} diff --git a/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/OneOffSetRefreshableGetTest.scala b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/OneOffSetRefreshableGetTest.scala new file mode 100644 index 00000000..515d2dd9 --- /dev/null +++ b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/OneOffSetRefreshableGetTest.scala @@ -0,0 +1,104 @@ +package com.softwaremill.pekkohttpsession + +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest +import SessionDirectives._ +import SessionOptions._ +import org.scalatest._ +import matchers.should._ +import org.scalatest.flatspec.AnyFlatSpec + +class OneOffSetRefreshableGetTest extends AnyFlatSpec with ScalatestRouteTest with Matchers with MultipleTransportTest { + + import TestData._ + + implicit val storage: InMemoryRefreshTokenStorage[Map[String, String]] = new InMemoryRefreshTokenStorage[Map[String, String]] { + override def log(msg: String) = println(msg) + } + + def createRoutes(`using`: TestUsingTransport)(implicit manager: SessionManager[Map[String, String]]) = get { + path("set") { + setSession(oneOff, using.setSessionTransport, Map("k1" -> "v1")) { + complete { "ok" } + } + } ~ + path("getOpt") { + optionalSession(refreshable, using.getSessionTransport) { session => + complete { session.toString } + } + } ~ + path("touchReq") { + touchRequiredSession(refreshable, using.getSessionTransport) { session => + complete { session.toString } + } + } ~ + path("invalidate") { + invalidateSession(refreshable, using.getSessionTransport) { + complete { "ok" } + } + } + } + + List(TestUsingCookies, TestUsingHeaders).foreach { usingValue => + val p = s"Using ${usingValue.transportName}" + def routes(implicit manager: SMan) = createRoutes(usingValue)(manager) + + p should "read an optional session when only the session is set" in { + Get("/set") ~> routes ~> check { + val Some(session) = usingValue.getSession + + Get("/getOpt") ~> + addHeader(usingValue.setSessionHeader(session)) ~> + routes ~> + check { + responseAs[String] should be("Some(Map(k1 -> v1))") + } + } + } + + p should "invalidate a session" in { + Get("/set") ~> routes ~> check { + val Some(session) = usingValue.getSession + + Get("/invalidate") ~> + addHeader(usingValue.setSessionHeader(session)) ~> + routes ~> + check { + usingValue.isSessionExpired should be(true) + usingValue.getRefreshToken should be(None) + } + } + } + + p should "touch the session, without setting a refresh token" in { + Get("/set") ~> routes(manager_expires60_fixedTime) ~> check { + val Some(session1) = usingValue.getSession + + Get("/touchReq") ~> + addHeader(usingValue.setSessionHeader(session1)) ~> + routes(manager_expires60_fixedTime_plus30s) ~> + check { + responseAs[String] should be("Map(k1 -> v1)") + + val Some(session2) = usingValue.getSession + val token2Opt = usingValue.getRefreshToken + + // The session should be modified with a new expiry date + session1 should not be (session2) + + // No refresh token should be set + token2Opt should be(None) + + // 70 seconds from the initial session, only the touched one should work + Get("/touchReq") ~> + addHeader(usingValue.setSessionHeader(session2)) ~> + routes(manager_expires60_fixedTime_plus70s) ~> + check { + responseAs[String] should be("Map(k1 -> v1)") + usingValue.getRefreshToken should be(None) + } + } + } + } + } +} diff --git a/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/OneOffTest.scala b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/OneOffTest.scala new file mode 100644 index 00000000..50c7a189 --- /dev/null +++ b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/OneOffTest.scala @@ -0,0 +1,222 @@ +package com.softwaremill.pekkohttpsession + +import org.apache.pekko.http.scaladsl.model.headers.`Set-Cookie` +import org.apache.pekko.http.scaladsl.server.AuthorizationFailedRejection +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest +import SessionDirectives._ +import SessionOptions._ +import org.scalatest._ +import matchers.should._ +import org.scalatest.flatspec.AnyFlatSpec + +class OneOffTest extends AnyFlatSpec with ScalatestRouteTest with Matchers with MultipleTransportTest { + + import TestData._ + + def createRoutes(`using`: TestUsingTransport)(implicit manager: SMan) = get { + path("set") { + setSession(oneOff, using.setSessionTransport, Map("k1" -> "v1")) { + complete { + "ok" + } + } + } ~ + path("getOpt") { + optionalSession(oneOff, using.getSessionTransport) { session => + complete { + session.toString + } + } + } ~ + path("getReq") { + requiredSession(oneOff, using.getSessionTransport) { session => + complete { + session.toString + } + } + } ~ + path("touchReq") { + touchRequiredSession(oneOff, using.getSessionTransport) { session => + complete { + session.toString + } + } + } ~ + path("invalidate") { + invalidateSession(oneOff, using.getSessionTransport) { + complete { + "ok" + } + } + } + } + + "Using cookies" should "set the correct session cookie name" in { + Get("/set") ~> createRoutes(TestUsingCookies) ~> check { + val sessionCookieOption = header[`Set-Cookie`] + sessionCookieOption shouldBe defined + val Some(sessionCookie) = sessionCookieOption + + sessionCookie.cookie.name should be(TestUsingCookies.sessionCookieName) + } + } + + List(TestUsingCookies, TestUsingHeaders).foreach { usingValue => + val p = s"Using ${usingValue.transportName}" + def routes(implicit manager: SMan) = createRoutes(usingValue)(manager) + + p should "set the session" in { + Get("/set") ~> routes ~> check { + responseAs[String] should be("ok") + + val sessionOption = usingValue.getSession + sessionOption shouldBe defined + + usingValue.isSessionExpired should be(false) + } + } + + p should "read an optional session when the session is set" in { + Get("/set") ~> routes ~> check { + val Some(s) = usingValue.getSession + + Get("/getOpt") ~> addHeader(usingValue.setSessionHeader(s)) ~> routes ~> check { + responseAs[String] should be("Some(Map(k1 -> v1))") + } + } + } + + p should "read an optional session when the session is not set" in { + Get("/getOpt") ~> routes ~> check { + responseAs[String] should be("None") + } + } + + p should "read a required session when the session is set" in { + Get("/set") ~> routes ~> check { + val Some(s) = usingValue.getSession + + Get("/getReq") ~> addHeader(usingValue.setSessionHeader(s)) ~> routes ~> check { + responseAs[String] should be("Map(k1 -> v1)") + } + } + } + + p should "reject the request if the expiry is tampered with" in { + Get("/set") ~> routes ~> check { + val Some(s) = usingValue.getSession + val Array(sig, exp, data) = s.split("-") + val tamperedSession = s"$sig-${exp.toLong + 1}-$data" + + Get("/getReq") ~> addHeader(usingValue.setSessionHeader(tamperedSession)) ~> routes ~> check { + rejection should be(AuthorizationFailedRejection) + } + } + } + + p should "invalidate a session" in { + Get("/set") ~> routes ~> check { + val Some(s1) = usingValue.getSession + + Get("/invalidate") ~> addHeader(usingValue.setSessionHeader(s1)) ~> routes ~> check { + responseAs[String] should be("ok") + + usingValue.isSessionExpired should be(true) + } + } + } + + p should "reject the request if the session is not set" in { + Get("/getReq") ~> routes ~> check { + rejection should be(AuthorizationFailedRejection) + } + } + + p should "reject the request if the session is invalid" in { + Get("/getReq") ~> addHeader(usingValue.setSessionHeader("invalid")) ~> routes ~> check { + rejection should be(AuthorizationFailedRejection) + } + } + + p should "touch the session" in { + Get("/set") ~> routes(manager_expires60_fixedTime) ~> check { + val Some(s1) = usingValue.getSession + + Get("/touchReq") ~> + addHeader(usingValue.setSessionHeader(s1)) ~> + routes(manager_expires60_fixedTime_plus30s) ~> + check { + responseAs[String] should be("Map(k1 -> v1)") + + val Some(s2) = usingValue.getSession + + // The session should be modified with a new expiry date + s1 should not be (s2) + + // 70 seconds from the initial request, only the touched one should work + Get("/touchReq") ~> addHeader(usingValue.setSessionHeader(s1)) ~> + routes(manager_expires60_fixedTime_plus70s) ~> + check { + rejection should be(AuthorizationFailedRejection) + } + Get("/touchReq") ~> addHeader(usingValue.setSessionHeader(s2)) ~> + routes(manager_expires60_fixedTime_plus70s) ~> + check { + responseAs[String] should be("Map(k1 -> v1)") + } + } + } + } + + p should "reject v0.5.1 session without migration config" in { + Get("/set") ~> routes ~> check { + val data = Map("k1" -> "v1") + val now = System.currentTimeMillis() + val legacySession = Legacy.encodeV0_5_1(data, now, sessionConfig) + + Get("/getReq") ~> addHeader(usingValue.setSessionHeader(legacySession)) ~> routes ~> check { + rejection should be(AuthorizationFailedRejection) + } + } + } + + p should "migrate v0.5.1 session with migration config" in { + Get("/set") ~> routes ~> check { + val data = Map("k1" -> "v1") + val now = System.currentTimeMillis() + val legacySession = Legacy.encodeV0_5_1(data, now, sessionConfig) + + Get("/getReq") ~> addHeader(usingValue.setSessionHeader(legacySession)) ~> routes(manager_tokenMigrationFromV0_5_1) ~> check { + usingValue.getSession shouldBe defined + responseAs[String] should be(data.toString) + } + } + } + + p should "reject v0.5.2 session without migration config" in { + Get("/set") ~> routes ~> check { + val data = Map("k1" -> "v1") + val now = System.currentTimeMillis() + val legacySession = Legacy.encodeV0_5_2(data, now, sessionConfig) + + Get("/getReq") ~> addHeader(usingValue.setSessionHeader(legacySession)) ~> routes ~> check { + rejection should be(AuthorizationFailedRejection) + } + } + } + + p should "migrate v0.5.2 session with migration config" in { + Get("/set") ~> routes ~> check { + val data = Map("k1" -> "v1") + val now = System.currentTimeMillis() + val legacySession = Legacy.encodeV0_5_2(data, now, sessionConfig) + + Get("/getReq") ~> addHeader(usingValue.setSessionHeader(legacySession)) ~> routes(manager_tokenMigrationFromV0_5_2) ~> check { + usingValue.getSession shouldBe defined + responseAs[String] should be(data.toString) + } + } + } + } +} diff --git a/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/RefreshableTest.scala b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/RefreshableTest.scala new file mode 100644 index 00000000..6398ad4d --- /dev/null +++ b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/RefreshableTest.scala @@ -0,0 +1,322 @@ +package com.softwaremill.pekkohttpsession + +import org.apache.pekko.http.scaladsl.server.AuthorizationFailedRejection +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest +import SessionDirectives._ +import SessionOptions._ +import org.scalatest._ +import matchers.should._ +import org.scalatest.flatspec.AnyFlatSpec + +class RefreshableTest extends AnyFlatSpec with ScalatestRouteTest with Matchers with MultipleTransportTest { + + import TestData._ + + implicit val storage: InMemoryRefreshTokenStorage[Map[String, String]] = new InMemoryRefreshTokenStorage[Map[String, String]] { + override def log(msg: String) = println(msg) + } + + def createRoutes(`using`: TestUsingTransport)(implicit manager: SessionManager[Map[String, String]]) = get { + path("set") { + setSession(refreshable, using.setSessionTransport, Map("k1" -> "v1")) { + complete { "ok" } + } + } ~ + path("getOpt") { + optionalSession(refreshable, using.getSessionTransport) { session => + complete { session.toString } + } + } ~ + path("getReq") { + requiredSession(refreshable, using.getSessionTransport) { session => + complete { session.toString } + } + } ~ + path("touchReq") { + touchRequiredSession(refreshable, using.getSessionTransport) { session => + complete { session.toString } + } + } ~ + path("invalidate") { + invalidateSession(refreshable, using.getSessionTransport) { + complete { "ok" } + } + } + } + + "Using cookies" should "set the refresh token cookie to expire" in { + Get("/set") ~> createRoutes(TestUsingCookies) ~> check { + responseAs[String] should be("ok") + + TestUsingCookies.cookiesMap + .get(TestUsingCookies.refreshTokenCookieName) + .flatMap(_.maxAge) + .getOrElse(0L) should be > (60L * 60L * 24L * 29) + } + } + + List(TestUsingCookies, TestUsingHeaders).foreach { usingValue => + val p = s"Using ${usingValue.transportName}" + def routes(implicit manager: SMan) = createRoutes(usingValue)(manager) + + p should "set both the session and refresh token" in { + Get("/set") ~> routes ~> check { + responseAs[String] should be("ok") + + usingValue.getSession shouldBe defined + usingValue.countSessionHeaders should be(1) + usingValue.getRefreshToken shouldBe defined + usingValue.countRefreshTokenHeaders should be(1) + } + } + + p should "set a new refresh token when the session is set again" in { + Get("/set") ~> routes ~> check { + val Some(token1) = usingValue.getRefreshToken + + Get("/set") ~> + routes ~> + check { + val Some(token2) = usingValue.getRefreshToken + token1 should not be (token2) + } + } + } + + p should "read an optional session when both the session and refresh token are set" in { + Get("/set") ~> routes ~> check { + val Some(session) = usingValue.getSession + val Some(token) = usingValue.getRefreshToken + + Get("/getOpt") ~> + addHeader(usingValue.setSessionHeader(session)) ~> + addHeader(usingValue.setRefreshTokenHeader(token)) ~> + routes ~> + check { + usingValue.countSessionHeaders should be(0) + usingValue.countRefreshTokenHeaders should be(0) + responseAs[String] should be("Some(Map(k1 -> v1))") + } + } + } + + p should "read an optional session when only the session is set" in { + Get("/set") ~> routes ~> check { + val Some(session) = usingValue.getSession + + Get("/getOpt") ~> + addHeader(usingValue.setSessionHeader(session)) ~> + routes ~> + check { + usingValue.countSessionHeaders should be(0) + usingValue.countRefreshTokenHeaders should be(0) + responseAs[String] should be("Some(Map(k1 -> v1))") + } + } + } + + p should "set a new session after the session is re-created" in { + Get("/set") ~> routes ~> check { + val Some(token1) = usingValue.getRefreshToken + val session1 = usingValue.getSession + session1 shouldBe defined + + Get("/getOpt") ~> + addHeader(usingValue.setRefreshTokenHeader(token1)) ~> + routes ~> + check { + usingValue.countSessionHeaders should be(1) + usingValue.countRefreshTokenHeaders should be(1) + val session2 = usingValue.getSession + session2 shouldBe defined + session2 should not be (session1) + } + } + } + + p should "read an optional session when none is set" in { + Get("/getOpt") ~> routes ~> check { + usingValue.countSessionHeaders should be(0) + usingValue.countRefreshTokenHeaders should be(0) + responseAs[String] should be("None") + } + } + + p should "read an optional session when only the refresh token is set (re-create the session)" in { + Get("/set") ~> routes ~> check { + val Some(token) = usingValue.getRefreshToken + + Get("/getOpt") ~> + addHeader(usingValue.setRefreshTokenHeader(token)) ~> + routes ~> + check { + usingValue.countSessionHeaders should be(1) + usingValue.countRefreshTokenHeaders should be(1) + responseAs[String] should be("Some(Map(k1 -> v1))") + } + } + } + + p should "set a new refresh token after the session is re-created" in { + Get("/set") ~> routes ~> check { + val Some(token1) = usingValue.getRefreshToken + + Get("/getOpt") ~> + addHeader(usingValue.setRefreshTokenHeader(token1)) ~> + routes ~> + check { + usingValue.countSessionHeaders should be(1) + usingValue.countRefreshTokenHeaders should be(1) + val Some(token2) = usingValue.getRefreshToken + token1 should not be (token2) + } + } + } + + p should "read a required session when both the session and refresh token are set" in { + Get("/set") ~> routes ~> check { + val Some(session) = usingValue.getSession + val Some(token) = usingValue.getRefreshToken + + Get("/getReq") ~> + addHeader(usingValue.setSessionHeader(session)) ~> + addHeader(usingValue.setRefreshTokenHeader(token)) ~> + routes ~> + check { + usingValue.countSessionHeaders should be(0) + usingValue.countRefreshTokenHeaders should be(0) + responseAs[String] should be("Map(k1 -> v1)") + } + } + } + + p should "invalidate a session" in { + Get("/set") ~> routes ~> check { + val Some(session) = usingValue.getSession + val Some(token) = usingValue.getRefreshToken + + Get("/invalidate") ~> + addHeader(usingValue.setSessionHeader(session)) ~> + addHeader(usingValue.setRefreshTokenHeader(token)) ~> + routes ~> + check { + usingValue.countSessionHeaders should be(1) + usingValue.countRefreshTokenHeaders should be(1) + usingValue.isSessionExpired should be(true) + usingValue.isRefreshTokenExpired should be(true) + } + } + } + + p should "reject the request if the session is not set" in { + Get("/getReq") ~> routes ~> check { + rejection should be(AuthorizationFailedRejection) + } + } + + p should "reject the request if the session is invalid" in { + Get("/getReq") ~> addHeader(usingValue.setSessionHeader("invalid")) ~> routes ~> check { + rejection should be(AuthorizationFailedRejection) + } + } + + p should "reject the request if the refresh token is invalid" in { + Get("/getReq") ~> addHeader(usingValue.setRefreshTokenHeader("invalid")) ~> routes ~> check { + rejection should be(AuthorizationFailedRejection) + } + } + + p should "touch the session, keeping the refresh token token intact" in { + Get("/set") ~> routes(manager_expires60_fixedTime) ~> check { + val Some(session1) = usingValue.getSession + val Some(token1) = usingValue.getRefreshToken + + Get("/touchReq") ~> + addHeader(usingValue.setSessionHeader(session1)) ~> + addHeader(usingValue.setRefreshTokenHeader(token1)) ~> + routes(manager_expires60_fixedTime_plus30s) ~> + check { + responseAs[String] should be("Map(k1 -> v1)") + + val Some(session2) = usingValue.getSession + val token2Opt = usingValue.getRefreshToken + + // The session should be modified with a new expiry date + session1 should not be (session2) + + // But the refresh token token should remain the same; no new token should be set + token2Opt should be(None) + + // 70 seconds from the initial session, only the touched one should work + Get("/touchReq") ~> + addHeader(usingValue.setSessionHeader(session2)) ~> + addHeader(usingValue.setRefreshTokenHeader(token1)) ~> + routes(manager_expires60_fixedTime_plus70s) ~> + check { + responseAs[String] should be("Map(k1 -> v1)") + } + Get("/touchReq") ~> + addHeader(usingValue.setSessionHeader(session1)) ~> + routes(manager_expires60_fixedTime_plus70s) ~> + check { + rejection should be(AuthorizationFailedRejection) + } + // When sending the expired session and refresh token token, a new session should start + Get("/touchReq") ~> + addHeader(usingValue.setSessionHeader(session1)) ~> + addHeader(usingValue.setRefreshTokenHeader(token1)) ~> + routes(manager_expires60_fixedTime_plus70s) ~> + check { + responseAs[String] should be("Map(k1 -> v1)") + + val Some(session3) = usingValue.getSession + val token3Opt = usingValue.getRefreshToken + + // new token should be generated + session1 should not be (session3) + token3Opt shouldBe defined + } + } + } + } + + p should "re-create an expired session and send back new tokens without duplicate headers" in { + Get("/set") ~> routes(manager_expires60_fixedTime) ~> check { + val Some(session1) = usingValue.getSession + val Some(token1) = usingValue.getRefreshToken + + Get("/touchReq") ~> + addHeader(usingValue.setSessionHeader(session1)) ~> + addHeader(usingValue.setRefreshTokenHeader(token1)) ~> + routes(manager_expires60_fixedTime_plus70s) ~> + check { + usingValue.countSessionHeaders should be(1) + usingValue.countRefreshTokenHeaders should be(1) + + usingValue.getSession should not be session1 + usingValue.getRefreshToken should not be token1 + } + } + } + + p should "touch the session and send back session without duplicate headers" in { + Get("/set") ~> routes(manager_expires60_fixedTime) ~> check { + val Some(session1) = usingValue.getSession + val Some(token1) = usingValue.getRefreshToken + + Get("/touchReq") ~> + addHeader(usingValue.setSessionHeader(session1)) ~> + addHeader(usingValue.setRefreshTokenHeader(token1)) ~> + routes(manager_expires60_fixedTime_plus30s) ~> + check { + usingValue.countSessionHeaders should be(1) + usingValue.countRefreshTokenHeaders should be(0) + + usingValue.getSession should not be session1 + usingValue.getRefreshToken should not be defined + } + } + } + } +} diff --git a/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/SessionConfigTest.scala b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/SessionConfigTest.scala new file mode 100644 index 00000000..c0af1600 --- /dev/null +++ b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/SessionConfigTest.scala @@ -0,0 +1,170 @@ +package com.softwaremill.pekkohttpsession + +import java.security.{KeyPairGenerator, PrivateKey} +import java.util.Base64 + +import JwsAlgorithm.HmacSHA256 +import com.typesafe.config.ConfigValueFactory.fromAnyRef +import com.typesafe.config.{Config, ConfigFactory} +import org.scalatest._ +import matchers.should._ +import org.scalatest.flatspec.AnyFlatSpec + +import scala.concurrent.duration._ + +class SessionConfigTest extends AnyFlatSpec with Matchers with OptionValues { + + val fakeServerSecret = s"f4k3S3rv3rS3cr37-${"x" * 64}" + + def referenceConfWithSecret(serverSecret: String): Config = + ConfigFactory + .load("reference") + .withValue("pekko.http.session.server-secret", fromAnyRef(serverSecret)) + + def configWith(stringValue: String): Config = + ConfigFactory + .parseString(stringValue) + .withFallback(referenceConfWithSecret(fakeServerSecret)) + + it should "load and parse default (HS256) JWS config" in { + val fakeConfig = referenceConfWithSecret(fakeServerSecret) + fakeConfig.getString("pekko.http.session.jws.alg") should equal("HS256") + + val config = SessionConfig.fromConfig(fakeConfig) + config.jws.alg should equal(HmacSHA256(fakeServerSecret)) + } + + it should "load and parse HS256 JWS config" in { + val fakeConfig = referenceConfWithSecret(fakeServerSecret) + + val config = SessionConfig.fromConfig(fakeConfig) + config.jws.alg should equal(HmacSHA256(fakeServerSecret)) + } + + it should "load and parse RS256 JWS config" in { + val privateKey: PrivateKey = { + val keyPairGen = KeyPairGenerator.getInstance("RSA") + keyPairGen.initialize(4096) + val kp = keyPairGen.generateKeyPair() + kp.getPrivate + } + val encodedPrivateKey: String = Base64.getEncoder.encodeToString(privateKey.getEncoded) + val fakeConfig = configWith(s""" + |pekko.http.session.jws { + | alg = "RS256" + | rsa-private-key = "$encodedPrivateKey" + |} + """.stripMargin) + + val config = SessionConfig.fromConfig(fakeConfig) + config.jws.alg should equal(JwsAlgorithm.Rsa(privateKey)) + } + + it should "fail to load config due to missing RSA private key (alg = RS256)" in { + val fakeConfig = configWith(s""" + |pekko.http.session.jws { + | alg = "RS256" + |} + """.stripMargin) + val ex = intercept[IllegalArgumentException] { + SessionConfig.fromConfig(fakeConfig) + } + ex.getMessage should equal("pekko.http.session.jws.rsa-private-key must be defined in order to use alg = RS256") + } + + it should "fail to load config due to empty RSA private key (alg = RS256)" in { + val fakeConfig = configWith(s""" + |pekko.http.session.jws { + | alg = "RS256" + | rsa-private-key = "" + |} + """.stripMargin) + val ex = intercept[IllegalArgumentException] { + SessionConfig.fromConfig(fakeConfig) + } + ex.getMessage should equal("pekko.http.session.jws.rsa-private-key must be defined in order to use alg = RS256") + } + + it should "fail to load config due to invalid RSA private key (alg = RS256)" in { + val fakeConfig = configWith(s""" + |pekko.http.session.jws { + | alg = "RS256" + | rsa-private-key = "an invalid RSA key" + |} + """.stripMargin) + val ex = intercept[IllegalArgumentException] { + SessionConfig.fromConfig(fakeConfig) + } + ex.getMessage should equal("Invalid RSA private key") + } + + it should "fail to load config due to unsupported JWS alg" in { + val fakeConfig = configWith("""pekko.http.session.jws.alg = "UNSUPPORTED1" """) + val ex = intercept[IllegalArgumentException] { + SessionConfig.fromConfig(fakeConfig) + } + ex.getMessage should equal(s"Unsupported JWS alg 'UNSUPPORTED1'. Supported algorithms are: HS256, RS256") + } + + it should "load JWT config" in { + val fakeConfig = configWith( + """pekko.http.session.jwt { + |iss = "testIssuer" + |sub = "testSubject" + |aud = "testAudience" + |exp-timeout = 12 hours + |nbf-offset = 5 minutes + |include-iat = true + |include-jti = true + |} """.stripMargin) + val config = SessionConfig.fromConfig(fakeConfig) + + config.jwt.issuer.value should equal("testIssuer") + config.jwt.subject.value should equal("testSubject") + config.jwt.audience.value should equal("testAudience") + config.jwt.expirationTimeout.value should equal(12.hours.toSeconds) + config.jwt.notBeforeOffset.value should equal(5.minutes.toSeconds) + config.jwt.includeIssuedAt shouldBe true + config.jwt.includeRandomJwtId shouldBe true + } + + it should "fallback to empty JWT config (with default exp-timeout) if absent" in { + val config = SessionConfig.fromConfig(configWith("pekko.http.session.jwt = {}")) + + config.jwt.issuer should not be defined + config.jwt.subject should not be defined + config.jwt.audience should not be defined + // fallback to the session-max-age + config.jwt.expirationTimeout should equal(config.sessionMaxAgeSeconds) + config.jwt.notBeforeOffset should not be defined + config.jwt.includeIssuedAt shouldBe false + config.jwt.includeRandomJwtId shouldBe false + } + + it should "fallback to empty JWT config (without default exp-timeout) if absent" in { + val config = SessionConfig.fromConfig(configWith( + """pekko.http.session { + | jwt {} + | max-age = "none" + |}""".stripMargin)) + + config.jwt.issuer should not be defined + config.jwt.subject should not be defined + config.jwt.audience should not be defined + // fallback to the session-max-age + config.jwt.expirationTimeout should not be defined + config.jwt.notBeforeOffset should not be defined + config.jwt.includeIssuedAt shouldBe false + config.jwt.includeRandomJwtId shouldBe false + } + + it should "use max-age as a default value for jwt.expirationTimeout" in { + val config = SessionConfig.fromConfig(configWith( + """pekko.http.session { + | max-age = 10 seconds + |}""".stripMargin)) + + config.jwt.expirationTimeout.value should equal(10L) + } + +} diff --git a/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/SessionManagerBasicEncoderTest.scala b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/SessionManagerBasicEncoderTest.scala new file mode 100644 index 00000000..e2a0f127 --- /dev/null +++ b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/SessionManagerBasicEncoderTest.scala @@ -0,0 +1,103 @@ +package com.softwaremill.pekkohttpsession + +import org.scalacheck.{Gen, Prop, Properties} + +import scala.util.{Success, Try} + +object SessionManagerBasicEncoderTest extends Properties("SessionManagerBasicEncoder") { + + import Prop._ + + val secretGen = Gen.choose(64, 256).flatMap(size => Gen.listOfN(size, Gen.alphaNumChar).map(_.mkString)) + + property("encode+decode") = forAllNoShrink(secretGen) { (secret: String) => + forAll { (encrypt: Boolean, useMaxAgeSeconds: Boolean, data: Map[String, String]) => + val config = SessionConfig + .default(secret) + .copy(sessionEncryptData = encrypt) + .copy(sessionMaxAgeSeconds = if (useMaxAgeSeconds) Some(3600L) else None) + val manager = new SessionManager[Map[String, String]](config).clientSessionManager + + manager.decode(manager.encode(data)) == SessionResult.Decoded(data) + } + } + + property("doesn't decode expired session") = forAllNoShrink(secretGen) { (secret: String) => + forAll { (encrypt: Boolean, data: Map[String, String]) => + val config = SessionConfig + .default(secret) + .copy(sessionEncryptData = encrypt) + .copy(sessionMaxAgeSeconds = Some(20L)) // expires after 20s + val managerPast = new SessionManager[Map[String, String]](config) { + override def nowMillis = 8172L * 1000L + }.clientSessionManager + val managerFuture = new SessionManager[Map[String, String]](config) { + override def nowMillis = (8172L + 600L) * 1000L // 600s later + }.clientSessionManager + + managerFuture.decode(managerPast.encode(data)) == SessionResult.Expired + } + } + + property("doesn't decode session with tampered expiry") = forAllNoShrink(secretGen) { (secret: String) => + forAll { (data: Map[String, String], now: Long, delta: Int) => + (delta >= 0) ==> { + val config = SessionConfig.default(secret) + val encoder = new BasicSessionEncoder[Map[String, String]] + + val enc = encoder.encode(data, System.currentTimeMillis(), config) + val Array(sig, exp, payload) = enc.split("-", 3) + val tampered = s"$sig-${exp.toLong + delta}-$payload" + + // the signature should only match if we didn't add anything to the expiry date + encoder.decode(tampered, config).map(_.signatureMatches) == Success(delta == 0L) + } + } + } + + property("decodes v0.5.1 tokens with migration config") = forAllNoShrink(secretGen) { (secret: String) => + forAll { + (data: Map[String, String], + now: Long, + delta: Int, + tokenMigrationV0_5_2Enabled: Boolean, + tokenMigrationV0_5_3Enabled: Boolean) => + (data.nonEmpty) ==> { + val config = SessionConfig + .default(secret) + .copy(tokenMigrationV0_5_2Enabled = tokenMigrationV0_5_2Enabled, + tokenMigrationV0_5_3Enabled = tokenMigrationV0_5_3Enabled) + + val encoder = new BasicSessionEncoder[Map[String, String]] + val encodedLegacy = Legacy.encodeV0_5_1(data, System.currentTimeMillis(), config) + val decodedResult = encoder.decode(encodedLegacy, config) + + // Decode should only work if the migrations between encoded version 0.5.1 and the current version are enabled. + if (tokenMigrationV0_5_2Enabled && tokenMigrationV0_5_3Enabled) { + decodedResult.map(_.signatureMatches) == Success(true) + } else { + decodedResult.isFailure || decodedResult.map(_.signatureMatches) == Success(false) + } + } + } + } + + property("decodes v0.5.2 tokens with migration config") = forAllNoShrink(secretGen) { (secret: String) => + forAll { (data: Map[String, String], now: Long, delta: Int, tokenMigrationV0_5_3Enabled: Boolean) => + (data.nonEmpty) ==> { + val config = SessionConfig.default(secret).copy(tokenMigrationV0_5_3Enabled = tokenMigrationV0_5_3Enabled) + + val encoder = new BasicSessionEncoder[Map[String, String]] + val encodedLegacy = Legacy.encodeV0_5_2(data, System.currentTimeMillis(), config) + val decodedResult = encoder.decode(encodedLegacy, config) + + // Decode should only work if the migrations between encoded version 0.5.2 and the current version are enabled. + if (tokenMigrationV0_5_3Enabled) { + decodedResult.map(_.signatureMatches) == Success(true) + } else { + decodedResult.isFailure || decodedResult.map(_.signatureMatches) == Success(false) + } + } + } + } +} diff --git a/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/TestData.scala b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/TestData.scala new file mode 100644 index 00000000..f9dd63d1 --- /dev/null +++ b/pekko-http-session/core/src/test/scala/com/softwaremill/pekkohttpsession/TestData.scala @@ -0,0 +1,27 @@ +package com.softwaremill.pekkohttpsession + +object TestData { + val sessionConfig = SessionConfig.default( + "c05ll3lesrinf39t7mc5h6un6r0c69lgfno69dsak3vabeqamouq4328cuaekros401ajdpkh60rrtpd8ro24rbuqmgtnd1ebag6ljnb65i8a55d482ok7o0nch0bfbe") + implicit val manager: SessionManager[Map[String, String]] = new SessionManager[Map[String, String]](sessionConfig) + + type SMan = SessionManager[Map[String, String]] + + val sessionConfig_expires60 = sessionConfig.copy(sessionMaxAgeSeconds = Some(60)) + val manager_expires60_fixedTime = new SessionManager[Map[String, String]](sessionConfig_expires60) { + override def nowMillis = 3028L * 1000L + } + val manager_expires60_fixedTime_plus30s = new SessionManager[Map[String, String]](sessionConfig_expires60) { + override def nowMillis = (3028L + 30L) * 1000L + } + val manager_expires60_fixedTime_plus70s = new SessionManager[Map[String, String]](sessionConfig_expires60) { + override def nowMillis = (3028L + 70L) * 1000L + } + + val sessionConfig_tokenMigrationFromV0_5_1 = + sessionConfig.copy(tokenMigrationV0_5_2Enabled = true, tokenMigrationV0_5_3Enabled = true) + val manager_tokenMigrationFromV0_5_1 = new SessionManager[Map[String, String]](sessionConfig_tokenMigrationFromV0_5_1) + + val sessionConfig_tokenMigrationFromV0_5_2 = sessionConfig.copy(tokenMigrationV0_5_3Enabled = true) + val manager_tokenMigrationFromV0_5_2 = new SessionManager[Map[String, String]](sessionConfig_tokenMigrationFromV0_5_2) +} diff --git a/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/JavaExample.java b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/JavaExample.java new file mode 100644 index 00000000..c2992f44 --- /dev/null +++ b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/JavaExample.java @@ -0,0 +1,160 @@ +package com.softwaremill.pekkoexample; + +import org.apache.pekko.NotUsed; +import org.apache.pekko.actor.ActorSystem; +import org.apache.pekko.dispatch.MessageDispatcher; +import org.apache.pekko.http.javadsl.ConnectHttp; +import org.apache.pekko.http.javadsl.Http; +import org.apache.pekko.http.javadsl.ServerBinding; +import org.apache.pekko.http.javadsl.model.HttpRequest; +import org.apache.pekko.http.javadsl.model.HttpResponse; +import org.apache.pekko.http.javadsl.model.StatusCodes; +import org.apache.pekko.http.javadsl.model.Uri; +import org.apache.pekko.http.javadsl.server.Route; +import org.apache.pekko.http.javadsl.unmarshalling.Unmarshaller; +import org.apache.pekko.stream.ActorMaterializer; +import org.apache.pekko.stream.javadsl.Flow; +import com.softwaremill.pekkoexample.session.MyJavaSession; +import com.softwaremill.pekkohttpsession.BasicSessionEncoder; +import com.softwaremill.pekkohttpsession.CheckHeader; +import com.softwaremill.pekkohttpsession.RefreshTokenStorage; +import com.softwaremill.pekkohttpsession.Refreshable; +import com.softwaremill.pekkohttpsession.SessionConfig; +import com.softwaremill.pekkohttpsession.SessionEncoder; +import com.softwaremill.pekkohttpsession.SessionManager; +import com.softwaremill.pekkohttpsession.SetSessionTransport; +import com.softwaremill.pekkohttpsession.javadsl.HttpSessionAwareDirectives; +import com.softwaremill.pekkohttpsession.javadsl.InMemoryRefreshTokenStorage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.concurrent.CompletionStage; + +import static com.softwaremill.pekkohttpsession.javadsl.SessionTransports.CookieST; + + +public class JavaExample extends HttpSessionAwareDirectives { + + private static final Logger LOGGER = LoggerFactory.getLogger(JavaExample.class); + private static final String SECRET = "c05ll3lesrinf39t7mc5h6un6r0c69lgfno69dsak3vabeqamouq4328cuaekros401ajdpkh60rrtpd8ro24rbuqmgtnd1ebag6ljnb65i8a55d482ok7o0nch0bfbe"; + private static final SessionEncoder BASIC_ENCODER = new BasicSessionEncoder<>(MyJavaSession.getSerializer()); + + // in-memory refresh token storage + private static final RefreshTokenStorage REFRESH_TOKEN_STORAGE = new InMemoryRefreshTokenStorage() { + @Override + public void log(String msg) { + LOGGER.info(msg); + } + }; + + private Refreshable refreshable; + private SetSessionTransport sessionTransport; + + public JavaExample(MessageDispatcher dispatcher) { + super(new SessionManager<>( + SessionConfig.defaultConfig(SECRET), + BASIC_ENCODER + ) + ); + + // use Refreshable for sessions, which needs to be refreshed or OneOff otherwise + // using Refreshable, a refresh token is set in form of a cookie or a custom header + refreshable = new Refreshable<>(getSessionManager(), REFRESH_TOKEN_STORAGE, dispatcher); + + // set the session transport - based on Cookies (or Headers) + sessionTransport = CookieST; + } + + public static void main(String[] args) throws IOException { + + // ** pekko-http boiler plate ** + ActorSystem system = ActorSystem.create("pekkoexample"); + final ActorMaterializer materializer = ActorMaterializer.create(system); + final Http http = Http.get(system); + + // ** pekko-http-session setup ** + MessageDispatcher dispatcher = system.dispatchers().lookup("pekko.actor.default-dispatcher"); + final JavaExample app = new JavaExample(dispatcher); + + // ** pekko-http boiler plate continued ** + final Flow routes = app.createRoutes().flow(system, materializer); + final CompletionStage binding = http.bindAndHandle(routes, ConnectHttp.toHost("localhost", 8080), materializer); + + System.out.println("Server started, press enter to stop"); + System.in.read(); + + binding + .thenCompose(ServerBinding::unbind) + .thenAccept(unbound -> system.terminate()); + } + + private Route createRoutes() { + CheckHeader checkHeader = new CheckHeader<>(getSessionManager()); + return + route( + pathSingleSlash(() -> + redirect(Uri.create("/site/index.html"), StatusCodes.FOUND) + ), + randomTokenCsrfProtection(checkHeader, () -> + route( + pathPrefix("api", () -> + route( + path("do_login", () -> + post(() -> + entity(Unmarshaller.entityToString(), body -> { + LOGGER.info("Logging in {}", body); + return setSession(refreshable, sessionTransport, new MyJavaSession(body), () -> + setNewCsrfToken(checkHeader, () -> + extractRequestContext(ctx -> + onSuccess(() -> ctx.completeWith(HttpResponse.create()), routeResult -> + complete("ok") + ) + ) + ) + ); + } + ) + ) + ), + + // This should be protected and accessible only when logged in + path("do_logout", () -> + post(() -> + requiredSession(refreshable, sessionTransport, session -> + invalidateSession(refreshable, sessionTransport, () -> + extractRequestContext(ctx -> { + LOGGER.info("Logging out {}", session.getUsername()); + return onSuccess(() -> ctx.completeWith(HttpResponse.create()), routeResult -> + complete("ok") + ); + } + ) + ) + ) + ) + ), + + // This should be protected and accessible only when logged in + path("current_login", () -> + get(() -> + requiredSession(refreshable, sessionTransport, session -> + extractRequestContext(ctx -> { + LOGGER.info("Current session: " + session); + return onSuccess(() -> ctx.completeWith(HttpResponse.create()), routeResult -> + complete(session.getUsername()) + ); + } + ) + ) + ) + ) + ) + ), + pathPrefix("site", () -> + getFromResourceDirectory("")) + ) + ) + ); + } +} diff --git a/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/SomeJavaComplexObject.java b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/SomeJavaComplexObject.java new file mode 100644 index 00000000..f35b75df --- /dev/null +++ b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/SomeJavaComplexObject.java @@ -0,0 +1,14 @@ +package com.softwaremill.pekkoexample; + +public class SomeJavaComplexObject { + + private final String value; + + public SomeJavaComplexObject(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/jwt/JavaJwtExample.java b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/jwt/JavaJwtExample.java new file mode 100644 index 00000000..2902d458 --- /dev/null +++ b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/jwt/JavaJwtExample.java @@ -0,0 +1,160 @@ +package com.softwaremill.pekkoexample.jwt; + +import org.apache.pekko.NotUsed; +import org.apache.pekko.actor.ActorSystem; +import org.apache.pekko.dispatch.MessageDispatcher; +import org.apache.pekko.http.javadsl.ConnectHttp; +import org.apache.pekko.http.javadsl.Http; +import org.apache.pekko.http.javadsl.ServerBinding; +import org.apache.pekko.http.javadsl.model.HttpRequest; +import org.apache.pekko.http.javadsl.model.HttpResponse; +import org.apache.pekko.http.javadsl.model.StatusCodes; +import org.apache.pekko.http.javadsl.model.Uri; +import org.apache.pekko.http.javadsl.server.Route; +import org.apache.pekko.http.javadsl.unmarshalling.Unmarshaller; +import org.apache.pekko.stream.ActorMaterializer; +import org.apache.pekko.stream.javadsl.Flow; +import com.softwaremill.pekkohttpsession.CheckHeader; +import com.softwaremill.pekkohttpsession.JwtSessionEncoder; +import com.softwaremill.pekkohttpsession.RefreshTokenStorage; +import com.softwaremill.pekkohttpsession.Refreshable; +import com.softwaremill.pekkohttpsession.SessionConfig; +import com.softwaremill.pekkohttpsession.SessionEncoder; +import com.softwaremill.pekkohttpsession.SessionManager; +import com.softwaremill.pekkohttpsession.SetSessionTransport; +import com.softwaremill.pekkohttpsession.javadsl.HttpSessionAwareDirectives; +import com.softwaremill.pekkohttpsession.javadsl.InMemoryRefreshTokenStorage; +import com.softwaremill.pekkohttpsession.javadsl.JwtSessionSerializers; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.concurrent.CompletionStage; + +import static com.softwaremill.pekkohttpsession.javadsl.SessionTransports.CookieST; + + +public class JavaJwtExample extends HttpSessionAwareDirectives { + + private static final Logger LOGGER = LoggerFactory.getLogger(JavaJwtExample.class); + private static final String SECRET = "c05ll3lesrinf39t7mc5h6un6r0c69lgfno69dsak3vabeqamouq4328cuaekros401ajdpkh60rrtpd8ro24rbuqmgtnd1ebag6ljnb65i8a55d482ok7o0nch0bfbe"; + private static final SessionEncoder JWT_ENCODER = new JwtSessionEncoder<>(JwtSessionSerializers.StringToJValueSessionSerializer, JwtSessionSerializers.DefaultUtcDateFormat); + + // in-memory refresh token storage + private static final RefreshTokenStorage REFRESH_TOKEN_STORAGE = new InMemoryRefreshTokenStorage() { + @Override + public void log(String msg) { + LOGGER.info(msg); + } + }; + + private Refreshable refreshable; + private SetSessionTransport sessionTransport; + + public JavaJwtExample(MessageDispatcher dispatcher) { + super(new SessionManager<>( + SessionConfig.defaultConfig(SECRET), + JWT_ENCODER + ) + ); + + // use Refreshable for sessions, which needs to be refreshed or OneOff otherwise + // using Refreshable, a refresh token is set in form of a cookie or a custom header + refreshable = new Refreshable<>(getSessionManager(), REFRESH_TOKEN_STORAGE, dispatcher); + + // set the session transport - based on Cookies (or Headers) + sessionTransport = CookieST; + } + + public static void main(String[] args) throws IOException { + + // ** pekko-http boiler plate ** + ActorSystem system = ActorSystem.create("pekkoexample"); + final ActorMaterializer materializer = ActorMaterializer.create(system); + final Http http = Http.get(system); + + // ** pekko-http-session setup ** + MessageDispatcher dispatcher = system.dispatchers().lookup("pekko.actor.default-dispatcher"); + final JavaJwtExample app = new JavaJwtExample(dispatcher); + + // ** pekko-http boiler plate continued ** + final Flow routes = app.createRoutes().flow(system, materializer); + final CompletionStage binding = http.bindAndHandle(routes, ConnectHttp.toHost("localhost", 8080), materializer); + + System.out.println("Server started, press enter to stop"); + System.in.read(); + + binding + .thenCompose(ServerBinding::unbind) + .thenAccept(unbound -> system.terminate()); + } + + private Route createRoutes() { + CheckHeader checkHeader = new CheckHeader<>(getSessionManager()); + return + route( + pathSingleSlash(() -> + redirect(Uri.create("/site/index.html"), StatusCodes.FOUND) + ), + randomTokenCsrfProtection(checkHeader, () -> + route( + pathPrefix("api", () -> + route( + path("do_login", () -> + post(() -> + entity(Unmarshaller.entityToString(), body -> { + LOGGER.info("Logging in {}", body); + return setSession(refreshable, sessionTransport, body, () -> + setNewCsrfToken(checkHeader, () -> + extractRequestContext(ctx -> + onSuccess(() -> ctx.completeWith(HttpResponse.create()), routeResult -> + complete("ok") + ) + ) + ) + ); + } + ) + ) + ), + + // This should be protected and accessible only when logged in + path("do_logout", () -> + post(() -> + requiredSession(refreshable, sessionTransport, session -> + invalidateSession(refreshable, sessionTransport, () -> + extractRequestContext(ctx -> { + LOGGER.info("Logging out {}", session); + return onSuccess(() -> ctx.completeWith(HttpResponse.create()), routeResult -> + complete("ok") + ); + } + ) + ) + ) + ) + ), + + // This should be protected and accessible only when logged in + path("current_login", () -> + get(() -> + requiredSession(refreshable, sessionTransport, session -> + extractRequestContext(ctx -> { + LOGGER.info("Current session: " + session); + return onSuccess(() -> ctx.completeWith(HttpResponse.create()), routeResult -> + complete(session) + ); + } + ) + ) + ) + ) + ) + ), + pathPrefix("site", () -> + getFromResourceDirectory("")) + ) + ) + ); + } +} diff --git a/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/serializers/MyJavaMultiValueSessionSerializer.java b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/serializers/MyJavaMultiValueSessionSerializer.java new file mode 100644 index 00000000..a19f42ae --- /dev/null +++ b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/serializers/MyJavaMultiValueSessionSerializer.java @@ -0,0 +1,34 @@ +package com.softwaremill.pekkoexample.serializers; + +import com.softwaremill.pekkoexample.SomeJavaComplexObject; +import com.softwaremill.pekkohttpsession.MultiValueSessionSerializer; +import com.softwaremill.pekkohttpsession.converters.MapConverters; +import scala.Function1; +import scala.collection.immutable.Map; +import scala.compat.java8.JFunction0; +import scala.compat.java8.JFunction1; +import scala.util.Try; + +import java.util.HashMap; + +public class MyJavaMultiValueSessionSerializer extends MultiValueSessionSerializer { + + public MyJavaMultiValueSessionSerializer( + Function1> toMap, + Function1, Try> fromMap + ) { + super(toMap, fromMap); + } + + public static void main(String[] args) { + new MyJavaMultiValueSessionSerializer( + (JFunction1>) sjco -> { + final java.util.Map m = new HashMap<>(); + m.put("value", sjco.getValue()); + return MapConverters.toImmutableMap(m); + }, + (JFunction1, Try>) value -> + Try.apply((JFunction0) () -> new SomeJavaComplexObject(value.get("value").get())) + ); + } +} diff --git a/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/serializers/MyJavaSingleSessionSerializer.java b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/serializers/MyJavaSingleSessionSerializer.java new file mode 100644 index 00000000..00489432 --- /dev/null +++ b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/serializers/MyJavaSingleSessionSerializer.java @@ -0,0 +1,29 @@ +package com.softwaremill.pekkoexample.serializers; + +import com.softwaremill.pekkoexample.SomeJavaComplexObject; +import com.softwaremill.pekkohttpsession.SessionSerializer; +import com.softwaremill.pekkohttpsession.SingleValueSessionSerializer; +import com.softwaremill.pekkohttpsession.javadsl.SessionSerializers; +import scala.Function1; +import scala.compat.java8.JFunction0; +import scala.compat.java8.JFunction1; +import scala.util.Try; + +public class MyJavaSingleSessionSerializer extends SingleValueSessionSerializer { + + public MyJavaSingleSessionSerializer( + Function1 toValue, + Function1> fromValue, + SessionSerializer valueSerializer + ) { + super(toValue, fromValue, valueSerializer); + } + + public static void main(String[] args) { + new MyJavaSingleSessionSerializer( + (JFunction1) SomeJavaComplexObject::getValue, + (JFunction1>) value -> Try.apply((JFunction0) () -> new SomeJavaComplexObject(value)), + SessionSerializers.StringToStringSessionSerializer + ); + } +} diff --git a/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/MyJavaSession.java b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/MyJavaSession.java new file mode 100644 index 00000000..4d0d4dea --- /dev/null +++ b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/MyJavaSession.java @@ -0,0 +1,38 @@ +package com.softwaremill.pekkoexample.session; + +import com.softwaremill.pekkohttpsession.SessionSerializer; +import com.softwaremill.pekkohttpsession.SingleValueSessionSerializer; +import com.softwaremill.pekkohttpsession.javadsl.SessionSerializers; +import scala.compat.java8.JFunction0; +import scala.compat.java8.JFunction1; +import scala.util.Try; + +public class MyJavaSession { + + /** + * This session serializer converts a session type into a value (always a String type). The first two arguments are just conversion functions. + * The third argument is a serializer responsible for preparing the data to be sent/received over the wire. There are some ready-to-use serializers available + * in the com.softwaremill.pekkohttpsession.SessionSerializer companion object, like stringToString and mapToString, just to name a few. + */ + private static final SessionSerializer serializer = new SingleValueSessionSerializer<>( + (JFunction1) (session) -> (session.getUsername()) + , + (JFunction1>) (login) -> Try.apply((JFunction0) (() -> new MyJavaSession(login))) + , + SessionSerializers.StringToStringSessionSerializer + ); + + private final String username; + + public MyJavaSession(String username) { + this.username = username; + } + + public static SessionSerializer getSerializer() { + return serializer; + } + + public String getUsername() { + return username; + } +} diff --git a/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/SessionInvalidationJava.java b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/SessionInvalidationJava.java new file mode 100644 index 00000000..9f910a0d --- /dev/null +++ b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/SessionInvalidationJava.java @@ -0,0 +1,113 @@ +package com.softwaremill.pekkoexample.session; + +import org.apache.pekko.NotUsed; +import org.apache.pekko.actor.ActorSystem; +import org.apache.pekko.dispatch.MessageDispatcher; +import org.apache.pekko.http.javadsl.ConnectHttp; +import org.apache.pekko.http.javadsl.Http; +import org.apache.pekko.http.javadsl.ServerBinding; +import org.apache.pekko.http.javadsl.model.HttpRequest; +import org.apache.pekko.http.javadsl.model.HttpResponse; +import org.apache.pekko.http.javadsl.server.Route; +import org.apache.pekko.stream.ActorMaterializer; +import org.apache.pekko.stream.javadsl.Flow; +import com.softwaremill.pekkohttpsession.BasicSessionEncoder; +import com.softwaremill.pekkohttpsession.CheckHeader; +import com.softwaremill.pekkohttpsession.RefreshTokenStorage; +import com.softwaremill.pekkohttpsession.Refreshable; +import com.softwaremill.pekkohttpsession.SessionConfig; +import com.softwaremill.pekkohttpsession.SessionEncoder; +import com.softwaremill.pekkohttpsession.SessionManager; +import com.softwaremill.pekkohttpsession.SetSessionTransport; +import com.softwaremill.pekkohttpsession.javadsl.HttpSessionAwareDirectives; +import com.softwaremill.pekkohttpsession.javadsl.InMemoryRefreshTokenStorage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.concurrent.CompletionStage; + +import static com.softwaremill.pekkohttpsession.javadsl.SessionTransports.CookieST; + +public class SessionInvalidationJava extends HttpSessionAwareDirectives { + + private static final Logger LOGGER = LoggerFactory.getLogger(SessionInvalidationJava.class); + private static final String SECRET = "c05ll3lesrinf39t7mc5h6un6r0c69lgfno69dsak3vabeqamouq4328cuaekros401ajdpkh60rrtpd8ro24rbuqmgtnd1ebag6ljnb65i8a55d482ok7o0nch0bfbe"; + private static final SessionEncoder BASIC_ENCODER = new BasicSessionEncoder<>(MyJavaSession.getSerializer()); + + // in-memory refresh token storage + private static final RefreshTokenStorage REFRESH_TOKEN_STORAGE = new InMemoryRefreshTokenStorage() { + @Override + public void log(String msg) { + LOGGER.info(msg); + } + }; + + private Refreshable refreshable; + private SetSessionTransport sessionTransport; + + public SessionInvalidationJava(MessageDispatcher dispatcher) { + super(new SessionManager<>( + SessionConfig.defaultConfig(SECRET), + BASIC_ENCODER + ) + ); + + // use Refreshable for sessions, which needs to be refreshed or OneOff otherwise + // using Refreshable, a refresh token is set in form of a cookie or a custom header + refreshable = new Refreshable<>(getSessionManager(), REFRESH_TOKEN_STORAGE, dispatcher); + + // set the session transport - based on Cookies (or Headers) + sessionTransport = CookieST; + } + + public static void main(String[] args) throws IOException { + + // ** pekko-http boiler plate ** + ActorSystem system = ActorSystem.create("pekkoexample"); + final ActorMaterializer materializer = ActorMaterializer.create(system); + final Http http = Http.get(system); + + // ** pekko-http-session setup ** + MessageDispatcher dispatcher = system.dispatchers().lookup("pekko.actor.default-dispatcher"); + final SessionInvalidationJava app = new SessionInvalidationJava(dispatcher); + + // ** pekko-http boiler plate continued ** + final Flow routes = app.createRoutes().flow(system, materializer); + final CompletionStage binding = http.bindAndHandle(routes, ConnectHttp.toHost("localhost", 8080), materializer); + + System.out.println("Server started, press enter to stop"); + System.in.read(); + + binding + .thenCompose(ServerBinding::unbind) + .thenAccept(unbound -> system.terminate()); + } + + private Route createRoutes() { + CheckHeader checkHeader = new CheckHeader<>(getSessionManager()); + return + route( + randomTokenCsrfProtection(checkHeader, () -> + route( + path("logout", () -> + post(() -> + requiredSession(refreshable, sessionTransport, session -> + invalidateSession(refreshable, sessionTransport, () -> + extractRequestContext(ctx -> { + LOGGER.info("Logging out {}", session.getUsername()); + return onSuccess(() -> ctx.completeWith(HttpResponse.create()), routeResult -> + complete("ok") + ); + } + ) + ) + ) + ) + ) + ) + ) + ); + } +} + diff --git a/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/SetSessionJava.java b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/SetSessionJava.java new file mode 100644 index 00000000..2112feee --- /dev/null +++ b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/SetSessionJava.java @@ -0,0 +1,111 @@ +package com.softwaremill.pekkoexample.session; + +import org.apache.pekko.NotUsed; +import org.apache.pekko.actor.ActorSystem; +import org.apache.pekko.dispatch.MessageDispatcher; +import org.apache.pekko.http.javadsl.ConnectHttp; +import org.apache.pekko.http.javadsl.Http; +import org.apache.pekko.http.javadsl.ServerBinding; +import org.apache.pekko.http.javadsl.model.HttpRequest; +import org.apache.pekko.http.javadsl.model.HttpResponse; +import org.apache.pekko.http.javadsl.server.Route; +import org.apache.pekko.http.javadsl.unmarshalling.Unmarshaller; +import org.apache.pekko.stream.ActorMaterializer; +import org.apache.pekko.stream.javadsl.Flow; +import com.softwaremill.pekkohttpsession.BasicSessionEncoder; +import com.softwaremill.pekkohttpsession.CheckHeader; +import com.softwaremill.pekkohttpsession.RefreshTokenStorage; +import com.softwaremill.pekkohttpsession.Refreshable; +import com.softwaremill.pekkohttpsession.SessionConfig; +import com.softwaremill.pekkohttpsession.SessionEncoder; +import com.softwaremill.pekkohttpsession.SessionManager; +import com.softwaremill.pekkohttpsession.SetSessionTransport; +import com.softwaremill.pekkohttpsession.javadsl.HttpSessionAwareDirectives; +import com.softwaremill.pekkohttpsession.javadsl.InMemoryRefreshTokenStorage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.concurrent.CompletionStage; + +import static com.softwaremill.pekkohttpsession.javadsl.SessionTransports.CookieST; + + +public class SetSessionJava extends HttpSessionAwareDirectives { + + private static final Logger LOGGER = LoggerFactory.getLogger(SetSessionJava.class); + + private static final String SECRET = "c05ll3lesrinf39t7mc5h6un6r0c69lgfno69dsak3vabeqamouq4328cuaekros401ajdpkh60rrtpd8ro24rbuqmgtnd1ebag6ljnb65i8a55d482ok7o0nch0bfbe"; + private static final SessionEncoder BASIC_ENCODER = new BasicSessionEncoder<>(MyJavaSession.getSerializer()); + + // in-memory refresh token storage + private static final RefreshTokenStorage REFRESH_TOKEN_STORAGE = new InMemoryRefreshTokenStorage() { + @Override + public void log(String msg) { + LOGGER.info(msg); + } + }; + + private Refreshable refreshable; + private SetSessionTransport sessionTransport; + + public SetSessionJava(MessageDispatcher dispatcher) { + super(new SessionManager<>( + SessionConfig.defaultConfig(SECRET), + BASIC_ENCODER + ) + ); + + refreshable = new Refreshable<>(getSessionManager(), REFRESH_TOKEN_STORAGE, dispatcher); + sessionTransport = CookieST; + } + + public static void main(String[] args) throws IOException { + + final ActorSystem system = ActorSystem.create("pekkoexample"); + final ActorMaterializer materializer = ActorMaterializer.create(system); + final Http http = Http.get(system); + + final MessageDispatcher dispatcher = system.dispatchers().lookup("pekko.actor.default-dispatcher"); + final SetSessionJava app = new SetSessionJava(dispatcher); + + final Flow routes = app.createRoutes().flow(system, materializer); + final CompletionStage binding = http.bindAndHandle(routes, ConnectHttp.toHost("localhost", 8080), materializer); + + System.out.println("Server started, press enter to stop"); + System.in.read(); + + binding + .thenCompose(ServerBinding::unbind) + .thenAccept(unbound -> system.terminate()); + } + + private Route createRoutes() { + CheckHeader checkHeader = new CheckHeader<>(getSessionManager()); + return + route( + randomTokenCsrfProtection(checkHeader, () -> + route( + path("login", () -> + post(() -> + entity(Unmarshaller.entityToString(), body -> { + LOGGER.info("Logging in {}", body); + return setSession(refreshable, sessionTransport, new MyJavaSession(body), () -> + setNewCsrfToken(checkHeader, () -> + extractRequestContext(ctx -> + onSuccess(() -> ctx.completeWith(HttpResponse.create()), routeResult -> + complete("ok") + ) + ) + ) + ); + } + ) + ) + ) + ) + ) + ); + } +} + diff --git a/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/VariousSessionsJava.java b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/VariousSessionsJava.java new file mode 100644 index 00000000..1123c64f --- /dev/null +++ b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/VariousSessionsJava.java @@ -0,0 +1,112 @@ +package com.softwaremill.pekkoexample.session; + +import org.apache.pekko.NotUsed; +import org.apache.pekko.actor.ActorSystem; +import org.apache.pekko.dispatch.MessageDispatcher; +import org.apache.pekko.http.javadsl.ConnectHttp; +import org.apache.pekko.http.javadsl.Http; +import org.apache.pekko.http.javadsl.ServerBinding; +import org.apache.pekko.http.javadsl.model.HttpRequest; +import org.apache.pekko.http.javadsl.model.HttpResponse; +import org.apache.pekko.http.javadsl.server.Route; +import org.apache.pekko.stream.ActorMaterializer; +import org.apache.pekko.stream.javadsl.Flow; +import com.softwaremill.pekkohttpsession.BasicSessionEncoder; +import com.softwaremill.pekkohttpsession.CheckHeader; +import com.softwaremill.pekkohttpsession.OneOff; +import com.softwaremill.pekkohttpsession.SessionConfig; +import com.softwaremill.pekkohttpsession.SessionEncoder; +import com.softwaremill.pekkohttpsession.SessionManager; +import com.softwaremill.pekkohttpsession.SessionResult; +import com.softwaremill.pekkohttpsession.SessionResult.Corrupt; +import com.softwaremill.pekkohttpsession.SessionResult.CreatedFromToken; +import com.softwaremill.pekkohttpsession.SessionResult.Decoded; +import com.softwaremill.pekkohttpsession.SessionResult.Expired$; +import com.softwaremill.pekkohttpsession.SessionResult.NoSession$; +import com.softwaremill.pekkohttpsession.SessionResult.TokenNotFound$; +import com.softwaremill.pekkohttpsession.SetSessionTransport; +import com.softwaremill.pekkohttpsession.javadsl.HttpSessionAwareDirectives; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.concurrent.CompletionStage; + +import static com.softwaremill.pekkohttpsession.javadsl.SessionTransports.CookieST; + +public class VariousSessionsJava extends HttpSessionAwareDirectives { + + private static final Logger LOGGER = LoggerFactory.getLogger(VariousSessionsJava.class); + + private static final String SECRET = "c05ll3lesrinf39t7mc5h6un6r0c69lgfno69dsak3vabeqamouq4328cuaekros401ajdpkh60rrtpd8ro24rbuqmgtnd1ebag6ljnb65i8a55d482ok7o0nch0bfbe"; + private static final SessionEncoder BASIC_ENCODER = new BasicSessionEncoder<>(MyJavaSession.getSerializer()); + + private OneOff oneOff; + private SetSessionTransport sessionTransport; + + public VariousSessionsJava(MessageDispatcher dispatcher) { + super(new SessionManager<>( + SessionConfig.defaultConfig(SECRET), + BASIC_ENCODER + ) + ); + + oneOff = new OneOff<>(getSessionManager()); + sessionTransport = CookieST; + } + + public static void main(String[] args) throws IOException { + + final ActorSystem system = ActorSystem.create("pekkoexample"); + final ActorMaterializer materializer = ActorMaterializer.create(system); + final Http http = Http.get(system); + + final MessageDispatcher dispatcher = system.dispatchers().lookup("pekko.actor.default-dispatcher"); + final VariousSessionsJava app = new VariousSessionsJava(dispatcher); + + final Flow routes = app.createRoutes().flow(system, materializer); + final CompletionStage binding = http.bindAndHandle(routes, ConnectHttp.toHost("localhost", 8080), materializer); + + System.out.println("Server started, press enter to stop"); + System.in.read(); + + binding + .thenCompose(ServerBinding::unbind) + .thenAccept(unbound -> system.terminate()); + } + + private Route createRoutes() { + CheckHeader checkHeader = new CheckHeader<>(getSessionManager()); + return + route( + randomTokenCsrfProtection(checkHeader, () -> + route( + path("secret", () -> + get(() -> requiredSession(oneOff, sessionTransport, myJavaSession -> complete("treasure"))) + ), + path("open", () -> + get(() -> optionalSession(oneOff, sessionTransport, myJavaSession -> complete("small treasure"))) + ), + path("detail", () -> session(oneOff, sessionTransport, sessionResult -> { + if (sessionResult instanceof Decoded) { + return complete("decoded"); + } else if (sessionResult instanceof CreatedFromToken) { + return complete("created from token"); + } else if (NoSession$.MODULE$.equals(sessionResult)) { + return complete("no session"); + } else if (TokenNotFound$.MODULE$.equals(sessionResult)) { + return complete("token not found"); + } else if (Expired$.MODULE$.equals(sessionResult)) { + return complete("expired"); + } else if (((SessionResult) sessionResult) instanceof Corrupt) { + return complete("corrupt"); + } + LOGGER.error("Unknown session result: {}", sessionResult); + throw new RuntimeException("What's going on here?"); + })) + ) + ) + ); + } +} + diff --git a/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/manager/MyJavaSessionManager.java b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/manager/MyJavaSessionManager.java new file mode 100644 index 00000000..eddf8841 --- /dev/null +++ b/pekko-http-session/example/src/main/java/com/softwaremill/pekkoexample/session/manager/MyJavaSessionManager.java @@ -0,0 +1,16 @@ +package com.softwaremill.pekkoexample.session.manager; + +import com.softwaremill.pekkohttpsession.BasicSessionEncoder; +import com.softwaremill.pekkohttpsession.SessionConfig; +import com.softwaremill.pekkohttpsession.SessionEncoder; +import com.softwaremill.pekkohttpsession.SessionManager; + +import static com.softwaremill.pekkohttpsession.javadsl.SessionSerializers.LongToStringSessionSerializer; + +public class MyJavaSessionManager { + + static final SessionEncoder BASIC_ENCODER = new BasicSessionEncoder<>(LongToStringSessionSerializer); + static final SessionConfig SESSION_CONFIG = SessionConfig.defaultConfig("some very long unusual string"); + static final SessionManager SESSION_MANAGER = new SessionManager<>(SESSION_CONFIG, BASIC_ENCODER); + +} diff --git a/pekko-http-session/example/src/main/resources/index.html b/pekko-http-session/example/src/main/resources/index.html new file mode 100644 index 00000000..f7da5682 --- /dev/null +++ b/pekko-http-session/example/src/main/resources/index.html @@ -0,0 +1,99 @@ + + + + + + + +

pekko-http-session example

+ +

+ Logged in as: +

+

+ Get login request status: +

+ +
+

+ Login: +

+ +
+ +
+ +
+ + + + diff --git a/pekko-http-session/example/src/main/resources/jquery.min.js b/pekko-http-session/example/src/main/resources/jquery.min.js new file mode 100644 index 00000000..7ac04f5b --- /dev/null +++ b/pekko-http-session/example/src/main/resources/jquery.min.js @@ -0,0 +1,4 @@ +/*! jQuery v1.11.1 | (c) 2005, 2014 jQuery Foundation, Inc. | jquery.org/license */ +!function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=c.slice,e=c.concat,f=c.push,g=c.indexOf,h={},i=h.toString,j=h.hasOwnProperty,k={},l="1.11.1",m=function(a,b){return new m.fn.init(a,b)},n=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,o=/^-ms-/,p=/-([\da-z])/gi,q=function(a,b){return b.toUpperCase()};m.fn=m.prototype={jquery:l,constructor:m,selector:"",length:0,toArray:function(){return d.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:d.call(this)},pushStack:function(a){var b=m.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a,b){return m.each(this,a,b)},map:function(a){return this.pushStack(m.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:c.sort,splice:c.splice},m.extend=m.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||m.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(e=arguments[h]))for(d in e)a=g[d],c=e[d],g!==c&&(j&&c&&(m.isPlainObject(c)||(b=m.isArray(c)))?(b?(b=!1,f=a&&m.isArray(a)?a:[]):f=a&&m.isPlainObject(a)?a:{},g[d]=m.extend(j,f,c)):void 0!==c&&(g[d]=c));return g},m.extend({expando:"jQuery"+(l+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===m.type(a)},isArray:Array.isArray||function(a){return"array"===m.type(a)},isWindow:function(a){return null!=a&&a==a.window},isNumeric:function(a){return!m.isArray(a)&&a-parseFloat(a)>=0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},isPlainObject:function(a){var b;if(!a||"object"!==m.type(a)||a.nodeType||m.isWindow(a))return!1;try{if(a.constructor&&!j.call(a,"constructor")&&!j.call(a.constructor.prototype,"isPrototypeOf"))return!1}catch(c){return!1}if(k.ownLast)for(b in a)return j.call(a,b);for(b in a);return void 0===b||j.call(a,b)},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?h[i.call(a)]||"object":typeof a},globalEval:function(b){b&&m.trim(b)&&(a.execScript||function(b){a.eval.call(a,b)})(b)},camelCase:function(a){return a.replace(o,"ms-").replace(p,q)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b,c){var d,e=0,f=a.length,g=r(a);if(c){if(g){for(;f>e;e++)if(d=b.apply(a[e],c),d===!1)break}else for(e in a)if(d=b.apply(a[e],c),d===!1)break}else if(g){for(;f>e;e++)if(d=b.call(a[e],e,a[e]),d===!1)break}else for(e in a)if(d=b.call(a[e],e,a[e]),d===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(n,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(r(Object(a))?m.merge(c,"string"==typeof a?[a]:a):f.call(c,a)),c},inArray:function(a,b,c){var d;if(b){if(g)return g.call(b,a,c);for(d=b.length,c=c?0>c?Math.max(0,d+c):c:0;d>c;c++)if(c in b&&b[c]===a)return c}return-1},merge:function(a,b){var c=+b.length,d=0,e=a.length;while(c>d)a[e++]=b[d++];if(c!==c)while(void 0!==b[d])a[e++]=b[d++];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,f=0,g=a.length,h=r(a),i=[];if(h)for(;g>f;f++)d=b(a[f],f,c),null!=d&&i.push(d);else for(f in a)d=b(a[f],f,c),null!=d&&i.push(d);return e.apply([],i)},guid:1,proxy:function(a,b){var c,e,f;return"string"==typeof b&&(f=a[b],b=a,a=f),m.isFunction(a)?(c=d.call(arguments,2),e=function(){return a.apply(b||this,c.concat(d.call(arguments)))},e.guid=a.guid=a.guid||m.guid++,e):void 0},now:function(){return+new Date},support:k}),m.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(a,b){h["[object "+b+"]"]=b.toLowerCase()});function r(a){var b=a.length,c=m.type(a);return"function"===c||m.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var s=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+-new Date,v=a.document,w=0,x=0,y=gb(),z=gb(),A=gb(),B=function(a,b){return a===b&&(l=!0),0},C="undefined",D=1<<31,E={}.hasOwnProperty,F=[],G=F.pop,H=F.push,I=F.push,J=F.slice,K=F.indexOf||function(a){for(var b=0,c=this.length;c>b;b++)if(this[b]===a)return b;return-1},L="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",N="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",O=N.replace("w","w#"),P="\\["+M+"*("+N+")(?:"+M+"*([*^$|!~]?=)"+M+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+O+"))|)"+M+"*\\]",Q=":("+N+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+P+")*)|.*)\\)|)",R=new RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),S=new RegExp("^"+M+"*,"+M+"*"),T=new RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),U=new RegExp("="+M+"*([^\\]'\"]*?)"+M+"*\\]","g"),V=new RegExp(Q),W=new RegExp("^"+O+"$"),X={ID:new RegExp("^#("+N+")"),CLASS:new RegExp("^\\.("+N+")"),TAG:new RegExp("^("+N.replace("w","w*")+")"),ATTR:new RegExp("^"+P),PSEUDO:new RegExp("^"+Q),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+L+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/^(?:input|select|textarea|button)$/i,Z=/^h\d$/i,$=/^[^{]+\{\s*\[native \w/,_=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ab=/[+~]/,bb=/'|\\/g,cb=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),db=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)};try{I.apply(F=J.call(v.childNodes),v.childNodes),F[v.childNodes.length].nodeType}catch(eb){I={apply:F.length?function(a,b){H.apply(a,J.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function fb(a,b,d,e){var f,h,j,k,l,o,r,s,w,x;if((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,d=d||[],!a||"string"!=typeof a)return d;if(1!==(k=b.nodeType)&&9!==k)return[];if(p&&!e){if(f=_.exec(a))if(j=f[1]){if(9===k){if(h=b.getElementById(j),!h||!h.parentNode)return d;if(h.id===j)return d.push(h),d}else if(b.ownerDocument&&(h=b.ownerDocument.getElementById(j))&&t(b,h)&&h.id===j)return d.push(h),d}else{if(f[2])return I.apply(d,b.getElementsByTagName(a)),d;if((j=f[3])&&c.getElementsByClassName&&b.getElementsByClassName)return I.apply(d,b.getElementsByClassName(j)),d}if(c.qsa&&(!q||!q.test(a))){if(s=r=u,w=b,x=9===k&&a,1===k&&"object"!==b.nodeName.toLowerCase()){o=g(a),(r=b.getAttribute("id"))?s=r.replace(bb,"\\$&"):b.setAttribute("id",s),s="[id='"+s+"'] ",l=o.length;while(l--)o[l]=s+qb(o[l]);w=ab.test(a)&&ob(b.parentNode)||b,x=o.join(",")}if(x)try{return I.apply(d,w.querySelectorAll(x)),d}catch(y){}finally{r||b.removeAttribute("id")}}}return i(a.replace(R,"$1"),b,d,e)}function gb(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function hb(a){return a[u]=!0,a}function ib(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function jb(a,b){var c=a.split("|"),e=a.length;while(e--)d.attrHandle[c[e]]=b}function kb(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||D)-(~a.sourceIndex||D);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function lb(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function mb(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function nb(a){return hb(function(b){return b=+b,hb(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function ob(a){return a&&typeof a.getElementsByTagName!==C&&a}c=fb.support={},f=fb.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=fb.setDocument=function(a){var b,e=a?a.ownerDocument||a:v,g=e.defaultView;return e!==n&&9===e.nodeType&&e.documentElement?(n=e,o=e.documentElement,p=!f(e),g&&g!==g.top&&(g.addEventListener?g.addEventListener("unload",function(){m()},!1):g.attachEvent&&g.attachEvent("onunload",function(){m()})),c.attributes=ib(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ib(function(a){return a.appendChild(e.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=$.test(e.getElementsByClassName)&&ib(function(a){return a.innerHTML="
",a.firstChild.className="i",2===a.getElementsByClassName("i").length}),c.getById=ib(function(a){return o.appendChild(a).id=u,!e.getElementsByName||!e.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if(typeof b.getElementById!==C&&p){var c=b.getElementById(a);return c&&c.parentNode?[c]:[]}},d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){var c=typeof a.getAttributeNode!==C&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return typeof b.getElementsByTagName!==C?b.getElementsByTagName(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return typeof b.getElementsByClassName!==C&&p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=$.test(e.querySelectorAll))&&(ib(function(a){a.innerHTML="",a.querySelectorAll("[msallowclip^='']").length&&q.push("[*^$]="+M+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+M+"*(?:value|"+L+")"),a.querySelectorAll(":checked").length||q.push(":checked")}),ib(function(a){var b=e.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+M+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=$.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ib(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",Q)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=$.test(o.compareDocumentPosition),t=b||$.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===e||a.ownerDocument===v&&t(v,a)?-1:b===e||b.ownerDocument===v&&t(v,b)?1:k?K.call(k,a)-K.call(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,f=a.parentNode,g=b.parentNode,h=[a],i=[b];if(!f||!g)return a===e?-1:b===e?1:f?-1:g?1:k?K.call(k,a)-K.call(k,b):0;if(f===g)return kb(a,b);c=a;while(c=c.parentNode)h.unshift(c);c=b;while(c=c.parentNode)i.unshift(c);while(h[d]===i[d])d++;return d?kb(h[d],i[d]):h[d]===v?-1:i[d]===v?1:0},e):n},fb.matches=function(a,b){return fb(a,null,null,b)},fb.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(U,"='$1']"),!(!c.matchesSelector||!p||r&&r.test(b)||q&&q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return fb(b,n,null,[a]).length>0},fb.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},fb.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&E.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},fb.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},fb.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=fb.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=fb.selectors={cacheLength:50,createPseudo:hb,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(cb,db),a[3]=(a[3]||a[4]||a[5]||"").replace(cb,db),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||fb.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&fb.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return X.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&V.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(cb,db).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+M+")"+a+"("+M+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||typeof a.getAttribute!==C&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=fb.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h;if(q){if(f){while(p){l=b;while(l=l[p])if(h?l.nodeName.toLowerCase()===r:1===l.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){k=q[u]||(q[u]={}),j=k[a]||[],n=j[0]===w&&j[1],m=j[0]===w&&j[2],l=n&&q.childNodes[n];while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if(1===l.nodeType&&++m&&l===b){k[a]=[w,n,m];break}}else if(s&&(j=(b[u]||(b[u]={}))[a])&&j[0]===w)m=j[1];else while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if((h?l.nodeName.toLowerCase()===r:1===l.nodeType)&&++m&&(s&&((l[u]||(l[u]={}))[a]=[w,m]),l===b))break;return m-=e,m===d||m%d===0&&m/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||fb.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?hb(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=K.call(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:hb(function(a){var b=[],c=[],d=h(a.replace(R,"$1"));return d[u]?hb(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),!c.pop()}}),has:hb(function(a){return function(b){return fb(a,b).length>0}}),contains:hb(function(a){return function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:hb(function(a){return W.test(a||"")||fb.error("unsupported lang: "+a),a=a.replace(cb,db).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Z.test(a.nodeName)},input:function(a){return Y.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:nb(function(){return[0]}),last:nb(function(a,b){return[b-1]}),eq:nb(function(a,b,c){return[0>c?c+b:c]}),even:nb(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:nb(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:nb(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:nb(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function rb(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(i=b[u]||(b[u]={}),(h=i[d])&&h[0]===w&&h[1]===f)return j[2]=h[2];if(i[d]=j,j[2]=a(b,c,g))return!0}}}function sb(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function tb(a,b,c){for(var d=0,e=b.length;e>d;d++)fb(a,b[d],c);return c}function ub(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(!c||c(f,d,e))&&(g.push(f),j&&b.push(h));return g}function vb(a,b,c,d,e,f){return d&&!d[u]&&(d=vb(d)),e&&!e[u]&&(e=vb(e,f)),hb(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||tb(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:ub(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=ub(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?K.call(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=ub(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):I.apply(g,r)})}function wb(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=rb(function(a){return a===b},h,!0),l=rb(function(a){return K.call(b,a)>-1},h,!0),m=[function(a,c,d){return!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d))}];f>i;i++)if(c=d.relative[a[i].type])m=[rb(sb(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return vb(i>1&&sb(m),i>1&&qb(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(R,"$1"),c,e>i&&wb(a.slice(i,e)),f>e&&wb(a=a.slice(e)),f>e&&qb(a))}m.push(c)}return sb(m)}function xb(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,m,o,p=0,q="0",r=f&&[],s=[],t=j,u=f||e&&d.find.TAG("*",k),v=w+=null==t?1:Math.random()||.1,x=u.length;for(k&&(j=g!==n&&g);q!==x&&null!=(l=u[q]);q++){if(e&&l){m=0;while(o=a[m++])if(o(l,g,h)){i.push(l);break}k&&(w=v)}c&&((l=!o&&l)&&p--,f&&r.push(l))}if(p+=q,c&&q!==p){m=0;while(o=b[m++])o(r,s,g,h);if(f){if(p>0)while(q--)r[q]||s[q]||(s[q]=G.call(i));s=ub(s)}I.apply(i,s),k&&!f&&s.length>0&&p+b.length>1&&fb.uniqueSort(i)}return k&&(w=v,j=t),r};return c?hb(f):f}return h=fb.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=wb(b[c]),f[u]?d.push(f):e.push(f);f=A(a,xb(e,d)),f.selector=a}return f},i=fb.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(cb,db),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=X.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(cb,db),ab.test(j[0].type)&&ob(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&qb(j),!a)return I.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,ab.test(a)&&ob(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ib(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),ib(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||jb("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ib(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||jb("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),ib(function(a){return null==a.getAttribute("disabled")})||jb(L,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),fb}(a);m.find=s,m.expr=s.selectors,m.expr[":"]=m.expr.pseudos,m.unique=s.uniqueSort,m.text=s.getText,m.isXMLDoc=s.isXML,m.contains=s.contains;var t=m.expr.match.needsContext,u=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,v=/^.[^:#\[\.,]*$/;function w(a,b,c){if(m.isFunction(b))return m.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return m.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(v.test(b))return m.filter(b,a,c);b=m.filter(b,a)}return m.grep(a,function(a){return m.inArray(a,b)>=0!==c})}m.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?m.find.matchesSelector(d,a)?[d]:[]:m.find.matches(a,m.grep(b,function(a){return 1===a.nodeType}))},m.fn.extend({find:function(a){var b,c=[],d=this,e=d.length;if("string"!=typeof a)return this.pushStack(m(a).filter(function(){for(b=0;e>b;b++)if(m.contains(d[b],this))return!0}));for(b=0;e>b;b++)m.find(a,d[b],c);return c=this.pushStack(e>1?m.unique(c):c),c.selector=this.selector?this.selector+" "+a:a,c},filter:function(a){return this.pushStack(w(this,a||[],!1))},not:function(a){return this.pushStack(w(this,a||[],!0))},is:function(a){return!!w(this,"string"==typeof a&&t.test(a)?m(a):a||[],!1).length}});var x,y=a.document,z=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,A=m.fn.init=function(a,b){var c,d;if(!a)return this;if("string"==typeof a){if(c="<"===a.charAt(0)&&">"===a.charAt(a.length-1)&&a.length>=3?[null,a,null]:z.exec(a),!c||!c[1]&&b)return!b||b.jquery?(b||x).find(a):this.constructor(b).find(a);if(c[1]){if(b=b instanceof m?b[0]:b,m.merge(this,m.parseHTML(c[1],b&&b.nodeType?b.ownerDocument||b:y,!0)),u.test(c[1])&&m.isPlainObject(b))for(c in b)m.isFunction(this[c])?this[c](b[c]):this.attr(c,b[c]);return this}if(d=y.getElementById(c[2]),d&&d.parentNode){if(d.id!==c[2])return x.find(a);this.length=1,this[0]=d}return this.context=y,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):m.isFunction(a)?"undefined"!=typeof x.ready?x.ready(a):a(m):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),m.makeArray(a,this))};A.prototype=m.fn,x=m(y);var B=/^(?:parents|prev(?:Until|All))/,C={children:!0,contents:!0,next:!0,prev:!0};m.extend({dir:function(a,b,c){var d=[],e=a[b];while(e&&9!==e.nodeType&&(void 0===c||1!==e.nodeType||!m(e).is(c)))1===e.nodeType&&d.push(e),e=e[b];return d},sibling:function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c}}),m.fn.extend({has:function(a){var b,c=m(a,this),d=c.length;return this.filter(function(){for(b=0;d>b;b++)if(m.contains(this,c[b]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=t.test(a)||"string"!=typeof a?m(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&m.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?m.unique(f):f)},index:function(a){return a?"string"==typeof a?m.inArray(this[0],m(a)):m.inArray(a.jquery?a[0]:a,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(m.unique(m.merge(this.get(),m(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function D(a,b){do a=a[b];while(a&&1!==a.nodeType);return a}m.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return m.dir(a,"parentNode")},parentsUntil:function(a,b,c){return m.dir(a,"parentNode",c)},next:function(a){return D(a,"nextSibling")},prev:function(a){return D(a,"previousSibling")},nextAll:function(a){return m.dir(a,"nextSibling")},prevAll:function(a){return m.dir(a,"previousSibling")},nextUntil:function(a,b,c){return m.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return m.dir(a,"previousSibling",c)},siblings:function(a){return m.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return m.sibling(a.firstChild)},contents:function(a){return m.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:m.merge([],a.childNodes)}},function(a,b){m.fn[a]=function(c,d){var e=m.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=m.filter(d,e)),this.length>1&&(C[a]||(e=m.unique(e)),B.test(a)&&(e=e.reverse())),this.pushStack(e)}});var E=/\S+/g,F={};function G(a){var b=F[a]={};return m.each(a.match(E)||[],function(a,c){b[c]=!0}),b}m.Callbacks=function(a){a="string"==typeof a?F[a]||G(a):m.extend({},a);var b,c,d,e,f,g,h=[],i=!a.once&&[],j=function(l){for(c=a.memory&&l,d=!0,f=g||0,g=0,e=h.length,b=!0;h&&e>f;f++)if(h[f].apply(l[0],l[1])===!1&&a.stopOnFalse){c=!1;break}b=!1,h&&(i?i.length&&j(i.shift()):c?h=[]:k.disable())},k={add:function(){if(h){var d=h.length;!function f(b){m.each(b,function(b,c){var d=m.type(c);"function"===d?a.unique&&k.has(c)||h.push(c):c&&c.length&&"string"!==d&&f(c)})}(arguments),b?e=h.length:c&&(g=d,j(c))}return this},remove:function(){return h&&m.each(arguments,function(a,c){var d;while((d=m.inArray(c,h,d))>-1)h.splice(d,1),b&&(e>=d&&e--,f>=d&&f--)}),this},has:function(a){return a?m.inArray(a,h)>-1:!(!h||!h.length)},empty:function(){return h=[],e=0,this},disable:function(){return h=i=c=void 0,this},disabled:function(){return!h},lock:function(){return i=void 0,c||k.disable(),this},locked:function(){return!i},fireWith:function(a,c){return!h||d&&!i||(c=c||[],c=[a,c.slice?c.slice():c],b?i.push(c):j(c)),this},fire:function(){return k.fireWith(this,arguments),this},fired:function(){return!!d}};return k},m.extend({Deferred:function(a){var b=[["resolve","done",m.Callbacks("once memory"),"resolved"],["reject","fail",m.Callbacks("once memory"),"rejected"],["notify","progress",m.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return m.Deferred(function(c){m.each(b,function(b,f){var g=m.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&m.isFunction(a.promise)?a.promise().done(c.resolve).fail(c.reject).progress(c.notify):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?m.extend(a,d):d}},e={};return d.pipe=d.then,m.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=d.call(arguments),e=c.length,f=1!==e||a&&m.isFunction(a.promise)?e:0,g=1===f?a:m.Deferred(),h=function(a,b,c){return function(e){b[a]=this,c[a]=arguments.length>1?d.call(arguments):e,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(e>1)for(i=new Array(e),j=new Array(e),k=new Array(e);e>b;b++)c[b]&&m.isFunction(c[b].promise)?c[b].promise().done(h(b,k,c)).fail(g.reject).progress(h(b,j,i)):--f;return f||g.resolveWith(k,c),g.promise()}});var H;m.fn.ready=function(a){return m.ready.promise().done(a),this},m.extend({isReady:!1,readyWait:1,holdReady:function(a){a?m.readyWait++:m.ready(!0)},ready:function(a){if(a===!0?!--m.readyWait:!m.isReady){if(!y.body)return setTimeout(m.ready);m.isReady=!0,a!==!0&&--m.readyWait>0||(H.resolveWith(y,[m]),m.fn.triggerHandler&&(m(y).triggerHandler("ready"),m(y).off("ready")))}}});function I(){y.addEventListener?(y.removeEventListener("DOMContentLoaded",J,!1),a.removeEventListener("load",J,!1)):(y.detachEvent("onreadystatechange",J),a.detachEvent("onload",J))}function J(){(y.addEventListener||"load"===event.type||"complete"===y.readyState)&&(I(),m.ready())}m.ready.promise=function(b){if(!H)if(H=m.Deferred(),"complete"===y.readyState)setTimeout(m.ready);else if(y.addEventListener)y.addEventListener("DOMContentLoaded",J,!1),a.addEventListener("load",J,!1);else{y.attachEvent("onreadystatechange",J),a.attachEvent("onload",J);var c=!1;try{c=null==a.frameElement&&y.documentElement}catch(d){}c&&c.doScroll&&!function e(){if(!m.isReady){try{c.doScroll("left")}catch(a){return setTimeout(e,50)}I(),m.ready()}}()}return H.promise(b)};var K="undefined",L;for(L in m(k))break;k.ownLast="0"!==L,k.inlineBlockNeedsLayout=!1,m(function(){var a,b,c,d;c=y.getElementsByTagName("body")[0],c&&c.style&&(b=y.createElement("div"),d=y.createElement("div"),d.style.cssText="position:absolute;border:0;width:0;height:0;top:0;left:-9999px",c.appendChild(d).appendChild(b),typeof b.style.zoom!==K&&(b.style.cssText="display:inline;margin:0;border:0;padding:1px;width:1px;zoom:1",k.inlineBlockNeedsLayout=a=3===b.offsetWidth,a&&(c.style.zoom=1)),c.removeChild(d))}),function(){var a=y.createElement("div");if(null==k.deleteExpando){k.deleteExpando=!0;try{delete a.test}catch(b){k.deleteExpando=!1}}a=null}(),m.acceptData=function(a){var b=m.noData[(a.nodeName+" ").toLowerCase()],c=+a.nodeType||1;return 1!==c&&9!==c?!1:!b||b!==!0&&a.getAttribute("classid")===b};var M=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,N=/([A-Z])/g;function O(a,b,c){if(void 0===c&&1===a.nodeType){var d="data-"+b.replace(N,"-$1").toLowerCase();if(c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:M.test(c)?m.parseJSON(c):c}catch(e){}m.data(a,b,c)}else c=void 0}return c}function P(a){var b;for(b in a)if(("data"!==b||!m.isEmptyObject(a[b]))&&"toJSON"!==b)return!1;return!0}function Q(a,b,d,e){if(m.acceptData(a)){var f,g,h=m.expando,i=a.nodeType,j=i?m.cache:a,k=i?a[h]:a[h]&&h; + if(k&&j[k]&&(e||j[k].data)||void 0!==d||"string"!=typeof b)return k||(k=i?a[h]=c.pop()||m.guid++:h),j[k]||(j[k]=i?{}:{toJSON:m.noop}),("object"==typeof b||"function"==typeof b)&&(e?j[k]=m.extend(j[k],b):j[k].data=m.extend(j[k].data,b)),g=j[k],e||(g.data||(g.data={}),g=g.data),void 0!==d&&(g[m.camelCase(b)]=d),"string"==typeof b?(f=g[b],null==f&&(f=g[m.camelCase(b)])):f=g,f}}function R(a,b,c){if(m.acceptData(a)){var d,e,f=a.nodeType,g=f?m.cache:a,h=f?a[m.expando]:m.expando;if(g[h]){if(b&&(d=c?g[h]:g[h].data)){m.isArray(b)?b=b.concat(m.map(b,m.camelCase)):b in d?b=[b]:(b=m.camelCase(b),b=b in d?[b]:b.split(" ")),e=b.length;while(e--)delete d[b[e]];if(c?!P(d):!m.isEmptyObject(d))return}(c||(delete g[h].data,P(g[h])))&&(f?m.cleanData([a],!0):k.deleteExpando||g!=g.window?delete g[h]:g[h]=null)}}}m.extend({cache:{},noData:{"applet ":!0,"embed ":!0,"object ":"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"},hasData:function(a){return a=a.nodeType?m.cache[a[m.expando]]:a[m.expando],!!a&&!P(a)},data:function(a,b,c){return Q(a,b,c)},removeData:function(a,b){return R(a,b)},_data:function(a,b,c){return Q(a,b,c,!0)},_removeData:function(a,b){return R(a,b,!0)}}),m.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=m.data(f),1===f.nodeType&&!m._data(f,"parsedAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=m.camelCase(d.slice(5)),O(f,d,e[d])));m._data(f,"parsedAttrs",!0)}return e}return"object"==typeof a?this.each(function(){m.data(this,a)}):arguments.length>1?this.each(function(){m.data(this,a,b)}):f?O(f,a,m.data(f,a)):void 0},removeData:function(a){return this.each(function(){m.removeData(this,a)})}}),m.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=m._data(a,b),c&&(!d||m.isArray(c)?d=m._data(a,b,m.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=m.queue(a,b),d=c.length,e=c.shift(),f=m._queueHooks(a,b),g=function(){m.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return m._data(a,c)||m._data(a,c,{empty:m.Callbacks("once memory").add(function(){m._removeData(a,b+"queue"),m._removeData(a,c)})})}}),m.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthh;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f},W=/^(?:checkbox|radio)$/i;!function(){var a=y.createElement("input"),b=y.createElement("div"),c=y.createDocumentFragment();if(b.innerHTML="
a",k.leadingWhitespace=3===b.firstChild.nodeType,k.tbody=!b.getElementsByTagName("tbody").length,k.htmlSerialize=!!b.getElementsByTagName("link").length,k.html5Clone="<:nav>"!==y.createElement("nav").cloneNode(!0).outerHTML,a.type="checkbox",a.checked=!0,c.appendChild(a),k.appendChecked=a.checked,b.innerHTML="",k.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue,c.appendChild(b),b.innerHTML="",k.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,k.noCloneEvent=!0,b.attachEvent&&(b.attachEvent("onclick",function(){k.noCloneEvent=!1}),b.cloneNode(!0).click()),null==k.deleteExpando){k.deleteExpando=!0;try{delete b.test}catch(d){k.deleteExpando=!1}}}(),function(){var b,c,d=y.createElement("div");for(b in{submit:!0,change:!0,focusin:!0})c="on"+b,(k[b+"Bubbles"]=c in a)||(d.setAttribute(c,"t"),k[b+"Bubbles"]=d.attributes[c].expando===!1);d=null}();var X=/^(?:input|select|textarea)$/i,Y=/^key/,Z=/^(?:mouse|pointer|contextmenu)|click/,$=/^(?:focusinfocus|focusoutblur)$/,_=/^([^.]*)(?:\.(.+)|)$/;function ab(){return!0}function bb(){return!1}function cb(){try{return y.activeElement}catch(a){}}m.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,n,o,p,q,r=m._data(a);if(r){c.handler&&(i=c,c=i.handler,e=i.selector),c.guid||(c.guid=m.guid++),(g=r.events)||(g=r.events={}),(k=r.handle)||(k=r.handle=function(a){return typeof m===K||a&&m.event.triggered===a.type?void 0:m.event.dispatch.apply(k.elem,arguments)},k.elem=a),b=(b||"").match(E)||[""],h=b.length;while(h--)f=_.exec(b[h])||[],o=q=f[1],p=(f[2]||"").split(".").sort(),o&&(j=m.event.special[o]||{},o=(e?j.delegateType:j.bindType)||o,j=m.event.special[o]||{},l=m.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&m.expr.match.needsContext.test(e),namespace:p.join(".")},i),(n=g[o])||(n=g[o]=[],n.delegateCount=0,j.setup&&j.setup.call(a,d,p,k)!==!1||(a.addEventListener?a.addEventListener(o,k,!1):a.attachEvent&&a.attachEvent("on"+o,k))),j.add&&(j.add.call(a,l),l.handler.guid||(l.handler.guid=c.guid)),e?n.splice(n.delegateCount++,0,l):n.push(l),m.event.global[o]=!0);a=null}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,n,o,p,q,r=m.hasData(a)&&m._data(a);if(r&&(k=r.events)){b=(b||"").match(E)||[""],j=b.length;while(j--)if(h=_.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=m.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,n=k[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),i=f=n.length;while(f--)g=n[f],!e&&q!==g.origType||c&&c.guid!==g.guid||h&&!h.test(g.namespace)||d&&d!==g.selector&&("**"!==d||!g.selector)||(n.splice(f,1),g.selector&&n.delegateCount--,l.remove&&l.remove.call(a,g));i&&!n.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||m.removeEvent(a,o,r.handle),delete k[o])}else for(o in k)m.event.remove(a,o+b[j],c,d,!0);m.isEmptyObject(k)&&(delete r.handle,m._removeData(a,"events"))}},trigger:function(b,c,d,e){var f,g,h,i,k,l,n,o=[d||y],p=j.call(b,"type")?b.type:b,q=j.call(b,"namespace")?b.namespace.split("."):[];if(h=l=d=d||y,3!==d.nodeType&&8!==d.nodeType&&!$.test(p+m.event.triggered)&&(p.indexOf(".")>=0&&(q=p.split("."),p=q.shift(),q.sort()),g=p.indexOf(":")<0&&"on"+p,b=b[m.expando]?b:new m.Event(p,"object"==typeof b&&b),b.isTrigger=e?2:3,b.namespace=q.join("."),b.namespace_re=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=d),c=null==c?[b]:m.makeArray(c,[b]),k=m.event.special[p]||{},e||!k.trigger||k.trigger.apply(d,c)!==!1)){if(!e&&!k.noBubble&&!m.isWindow(d)){for(i=k.delegateType||p,$.test(i+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),l=h;l===(d.ownerDocument||y)&&o.push(l.defaultView||l.parentWindow||a)}n=0;while((h=o[n++])&&!b.isPropagationStopped())b.type=n>1?i:k.bindType||p,f=(m._data(h,"events")||{})[b.type]&&m._data(h,"handle"),f&&f.apply(h,c),f=g&&h[g],f&&f.apply&&m.acceptData(h)&&(b.result=f.apply(h,c),b.result===!1&&b.preventDefault());if(b.type=p,!e&&!b.isDefaultPrevented()&&(!k._default||k._default.apply(o.pop(),c)===!1)&&m.acceptData(d)&&g&&d[p]&&!m.isWindow(d)){l=d[g],l&&(d[g]=null),m.event.triggered=p;try{d[p]()}catch(r){}m.event.triggered=void 0,l&&(d[g]=l)}return b.result}},dispatch:function(a){a=m.event.fix(a);var b,c,e,f,g,h=[],i=d.call(arguments),j=(m._data(this,"events")||{})[a.type]||[],k=m.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=m.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,g=0;while((e=f.handlers[g++])&&!a.isImmediatePropagationStopped())(!a.namespace_re||a.namespace_re.test(e.namespace))&&(a.handleObj=e,a.data=e.data,c=((m.event.special[e.origType]||{}).handle||e.handler).apply(f.elem,i),void 0!==c&&(a.result=c)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&(!a.button||"click"!==a.type))for(;i!=this;i=i.parentNode||this)if(1===i.nodeType&&(i.disabled!==!0||"click"!==a.type)){for(e=[],f=0;h>f;f++)d=b[f],c=d.selector+" ",void 0===e[c]&&(e[c]=d.needsContext?m(c,this).index(i)>=0:m.find(c,this,null,[i]).length),e[c]&&e.push(d);e.length&&g.push({elem:i,handlers:e})}return h]","i"),hb=/^\s+/,ib=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,jb=/<([\w:]+)/,kb=/\s*$/g,rb={option:[1,""],legend:[1,"
","
"],area:[1,"",""],param:[1,"",""],thead:[1,"","
"],tr:[2,"","
"],col:[2,"","
"],td:[3,"","
"],_default:k.htmlSerialize?[0,"",""]:[1,"X
","
"]},sb=db(y),tb=sb.appendChild(y.createElement("div"));rb.optgroup=rb.option,rb.tbody=rb.tfoot=rb.colgroup=rb.caption=rb.thead,rb.th=rb.td;function ub(a,b){var c,d,e=0,f=typeof a.getElementsByTagName!==K?a.getElementsByTagName(b||"*"):typeof a.querySelectorAll!==K?a.querySelectorAll(b||"*"):void 0;if(!f)for(f=[],c=a.childNodes||a;null!=(d=c[e]);e++)!b||m.nodeName(d,b)?f.push(d):m.merge(f,ub(d,b));return void 0===b||b&&m.nodeName(a,b)?m.merge([a],f):f}function vb(a){W.test(a.type)&&(a.defaultChecked=a.checked)}function wb(a,b){return m.nodeName(a,"table")&&m.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function xb(a){return a.type=(null!==m.find.attr(a,"type"))+"/"+a.type,a}function yb(a){var b=pb.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function zb(a,b){for(var c,d=0;null!=(c=a[d]);d++)m._data(c,"globalEval",!b||m._data(b[d],"globalEval"))}function Ab(a,b){if(1===b.nodeType&&m.hasData(a)){var c,d,e,f=m._data(a),g=m._data(b,f),h=f.events;if(h){delete g.handle,g.events={};for(c in h)for(d=0,e=h[c].length;e>d;d++)m.event.add(b,c,h[c][d])}g.data&&(g.data=m.extend({},g.data))}}function Bb(a,b){var c,d,e;if(1===b.nodeType){if(c=b.nodeName.toLowerCase(),!k.noCloneEvent&&b[m.expando]){e=m._data(b);for(d in e.events)m.removeEvent(b,d,e.handle);b.removeAttribute(m.expando)}"script"===c&&b.text!==a.text?(xb(b).text=a.text,yb(b)):"object"===c?(b.parentNode&&(b.outerHTML=a.outerHTML),k.html5Clone&&a.innerHTML&&!m.trim(b.innerHTML)&&(b.innerHTML=a.innerHTML)):"input"===c&&W.test(a.type)?(b.defaultChecked=b.checked=a.checked,b.value!==a.value&&(b.value=a.value)):"option"===c?b.defaultSelected=b.selected=a.defaultSelected:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}}m.extend({clone:function(a,b,c){var d,e,f,g,h,i=m.contains(a.ownerDocument,a);if(k.html5Clone||m.isXMLDoc(a)||!gb.test("<"+a.nodeName+">")?f=a.cloneNode(!0):(tb.innerHTML=a.outerHTML,tb.removeChild(f=tb.firstChild)),!(k.noCloneEvent&&k.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||m.isXMLDoc(a)))for(d=ub(f),h=ub(a),g=0;null!=(e=h[g]);++g)d[g]&&Bb(e,d[g]);if(b)if(c)for(h=h||ub(a),d=d||ub(f),g=0;null!=(e=h[g]);g++)Ab(e,d[g]);else Ab(a,f);return d=ub(f,"script"),d.length>0&&zb(d,!i&&ub(a,"script")),d=h=e=null,f},buildFragment:function(a,b,c,d){for(var e,f,g,h,i,j,l,n=a.length,o=db(b),p=[],q=0;n>q;q++)if(f=a[q],f||0===f)if("object"===m.type(f))m.merge(p,f.nodeType?[f]:f);else if(lb.test(f)){h=h||o.appendChild(b.createElement("div")),i=(jb.exec(f)||["",""])[1].toLowerCase(),l=rb[i]||rb._default,h.innerHTML=l[1]+f.replace(ib,"<$1>")+l[2],e=l[0];while(e--)h=h.lastChild;if(!k.leadingWhitespace&&hb.test(f)&&p.push(b.createTextNode(hb.exec(f)[0])),!k.tbody){f="table"!==i||kb.test(f)?""!==l[1]||kb.test(f)?0:h:h.firstChild,e=f&&f.childNodes.length;while(e--)m.nodeName(j=f.childNodes[e],"tbody")&&!j.childNodes.length&&f.removeChild(j)}m.merge(p,h.childNodes),h.textContent="";while(h.firstChild)h.removeChild(h.firstChild);h=o.lastChild}else p.push(b.createTextNode(f));h&&o.removeChild(h),k.appendChecked||m.grep(ub(p,"input"),vb),q=0;while(f=p[q++])if((!d||-1===m.inArray(f,d))&&(g=m.contains(f.ownerDocument,f),h=ub(o.appendChild(f),"script"),g&&zb(h),c)){e=0;while(f=h[e++])ob.test(f.type||"")&&c.push(f)}return h=null,o},cleanData:function(a,b){for(var d,e,f,g,h=0,i=m.expando,j=m.cache,l=k.deleteExpando,n=m.event.special;null!=(d=a[h]);h++)if((b||m.acceptData(d))&&(f=d[i],g=f&&j[f])){if(g.events)for(e in g.events)n[e]?m.event.remove(d,e):m.removeEvent(d,e,g.handle);j[f]&&(delete j[f],l?delete d[i]:typeof d.removeAttribute!==K?d.removeAttribute(i):d[i]=null,c.push(f))}}}),m.fn.extend({text:function(a){return V(this,function(a){return void 0===a?m.text(this):this.empty().append((this[0]&&this[0].ownerDocument||y).createTextNode(a))},null,a,arguments.length)},append:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=wb(this,a);b.appendChild(a)}})},prepend:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=wb(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},remove:function(a,b){for(var c,d=a?m.filter(a,this):this,e=0;null!=(c=d[e]);e++)b||1!==c.nodeType||m.cleanData(ub(c)),c.parentNode&&(b&&m.contains(c.ownerDocument,c)&&zb(ub(c,"script")),c.parentNode.removeChild(c));return this},empty:function(){for(var a,b=0;null!=(a=this[b]);b++){1===a.nodeType&&m.cleanData(ub(a,!1));while(a.firstChild)a.removeChild(a.firstChild);a.options&&m.nodeName(a,"select")&&(a.options.length=0)}return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return m.clone(this,a,b)})},html:function(a){return V(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a)return 1===b.nodeType?b.innerHTML.replace(fb,""):void 0;if(!("string"!=typeof a||mb.test(a)||!k.htmlSerialize&&gb.test(a)||!k.leadingWhitespace&&hb.test(a)||rb[(jb.exec(a)||["",""])[1].toLowerCase()])){a=a.replace(ib,"<$1>");try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(m.cleanData(ub(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=arguments[0];return this.domManip(arguments,function(b){a=this.parentNode,m.cleanData(ub(this)),a&&a.replaceChild(b,this)}),a&&(a.length||a.nodeType)?this:this.remove()},detach:function(a){return this.remove(a,!0)},domManip:function(a,b){a=e.apply([],a);var c,d,f,g,h,i,j=0,l=this.length,n=this,o=l-1,p=a[0],q=m.isFunction(p);if(q||l>1&&"string"==typeof p&&!k.checkClone&&nb.test(p))return this.each(function(c){var d=n.eq(c);q&&(a[0]=p.call(this,c,d.html())),d.domManip(a,b)});if(l&&(i=m.buildFragment(a,this[0].ownerDocument,!1,this),c=i.firstChild,1===i.childNodes.length&&(i=c),c)){for(g=m.map(ub(i,"script"),xb),f=g.length;l>j;j++)d=i,j!==o&&(d=m.clone(d,!0,!0),f&&m.merge(g,ub(d,"script"))),b.call(this[j],d,j);if(f)for(h=g[g.length-1].ownerDocument,m.map(g,yb),j=0;f>j;j++)d=g[j],ob.test(d.type||"")&&!m._data(d,"globalEval")&&m.contains(h,d)&&(d.src?m._evalUrl&&m._evalUrl(d.src):m.globalEval((d.text||d.textContent||d.innerHTML||"").replace(qb,"")));i=c=null}return this}}),m.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){m.fn[a]=function(a){for(var c,d=0,e=[],g=m(a),h=g.length-1;h>=d;d++)c=d===h?this:this.clone(!0),m(g[d])[b](c),f.apply(e,c.get());return this.pushStack(e)}});var Cb,Db={};function Eb(b,c){var d,e=m(c.createElement(b)).appendTo(c.body),f=a.getDefaultComputedStyle&&(d=a.getDefaultComputedStyle(e[0]))?d.display:m.css(e[0],"display");return e.detach(),f}function Fb(a){var b=y,c=Db[a];return c||(c=Eb(a,b),"none"!==c&&c||(Cb=(Cb||m("