Skip to content

Commit 23382d7

Browse files
committed
major refactoring of session framework
1 parent afa96d0 commit 23382d7

File tree

38 files changed

+591
-475
lines changed

38 files changed

+591
-475
lines changed

build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ ThisBuild / organization := "app.softnetwork"
88

99
name := "generic-persistence-api"
1010

11-
ThisBuild / version := "0.4.2"
11+
ThisBuild / version := "0.5.0"
1212

1313
ThisBuild / scalaVersion := "2.12.18"
1414

server/src/main/scala/app/softnetwork/api/server/ServiceEndpoints.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package app.softnetwork.api.server
33
import app.softnetwork.concurrent.Completion
44
import app.softnetwork.persistence.message.{Command, CommandResult, ErrorMessage}
55
import app.softnetwork.persistence.service.Service
6-
import app.softnetwork.persistence.typed.scaladsl.EntityPattern
6+
import app.softnetwork.persistence.typed.scaladsl.Patterns
77
import sttp.tapir.generic.auto.SchemaDerivation
88
import sttp.tapir.Tapir
99

@@ -14,7 +14,7 @@ trait ServiceEndpoints[C <: Command, R <: CommandResult]
1414
with Tapir
1515
with SchemaDerivation
1616
with Service[C, R]
17-
with Completion { _: EntityPattern[C, R] =>
17+
with Completion { _: Patterns[C, R] =>
1818

1919
implicit def resultToApiError(result: R): ApiErrors.ErrorInfo =
2020
result match {

session/common/src/main/protobuf/session.proto

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,6 @@ message Session {
2323
option (scalapb.message).extends = "SessionData";
2424
option (scalapb.message).extends = "SessionDecorator";
2525
option (scalapb.message).companion_extends = "SessionCompanion";
26-
map<string, string> data = 1 [(scalapb.field).map_type="collection.mutable.Map"];
26+
map<string, string> data = 1 [(scalapb.field).map_type="collection.immutable.Map", (scalapb.field).scala_name = "kvs"];
2727
bool refreshable = 2;
2828
}

session/common/src/main/resources/reference.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
akka {
22
http {
3-
session{
3+
session {
44
# The secret used to sign the session cookie. This should be changed to a random string.
55
server-secret = ";C5/n}5K&/AX<8SO`nNuGl*^>w[hOD7uuhFLt*y`QNTL8vqHDK9te+Pd+.,,'njk"
66
server-secret = ${?HTTP_SESSION_SERVER_SECRET}

session/common/src/main/scala/app/softnetwork/session/config/Settings.scala

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,34 @@ package app.softnetwork.session.config
22

33
/** Created by smanciot on 21/03/2018.
44
*/
5+
import com.softwaremill.session.SessionConfig
56
import com.typesafe.config.{Config, ConfigFactory}
67
import configs.ConfigError
78
import org.softnetwork.session.model.Session.SessionType
89

910
object Settings {
1011
lazy val config: Config = ConfigFactory.load()
1112

12-
def configErrorsToException(err: ConfigError) =
13+
def configErrorsToException: ConfigError => Throwable = err =>
1314
new IllegalStateException(err.entries.map(_.messageWithPath).mkString(","))
1415

1516
object Session {
16-
val CookieName: String = config getString "akka.http.session.cookie.name"
1717

18-
val CookieSecret: String = config getString "akka.http.session.server-secret"
19-
20-
val Continuity: String = config getString "akka.http.session.continuity"
21-
22-
val Transport: String = config getString "akka.http.session.transport"
18+
val DefaultSessionConfig: SessionConfig = SessionConfig.fromConfig(config)
19+
require(
20+
DefaultSessionConfig.serverSecret.nonEmpty,
21+
"akka.http.session.server-secret must not be empty"
22+
)
2323

24-
require(CookieName.nonEmpty, "akka.http.session.cookie.name must be non-empty")
25-
require(CookieSecret.nonEmpty, "akka.http.session.server-secret must be non-empty")
26-
require(Continuity.nonEmpty, "akka.http.session.continuity must be non-empty")
27-
require(Transport.nonEmpty, "akka.http.session.transport must be non-empty")
24+
val Continuity: String = (config getString "akka.http.session.continuity").toLowerCase
25+
require(Continuity.nonEmpty, "akka.http.session.continuity must not be empty")
2826
require(
2927
Continuity == "one-off" || Continuity == "refreshable",
3028
"akka.http.session.continuity must be one-off or refreshable"
3129
)
30+
31+
val Transport: String = (config getString "akka.http.session.transport").toLowerCase
32+
require(Transport.nonEmpty, "akka.http.session.transport must not be empty")
3233
require(
3334
Transport == "cookie" || Transport == "header",
3435
"akka.http.session.transport must be cookie or header"
Lines changed: 112 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,67 @@
11
package app.softnetwork.session.model
22

33
import java.util.UUID
4-
5-
import com.softwaremill.session.{SessionConfig, SessionManager, SessionSerializer}
4+
import com.softwaremill.session.{
5+
JValueSessionSerializer,
6+
JwtSessionEncoder,
7+
SessionConfig,
8+
SessionEncoder,
9+
SessionManager,
10+
SessionSerializer
11+
}
612
import app.softnetwork.persistence.model.ProtobufDomainObject
713
import app.softnetwork.session.config.Settings.Session._
814
import app.softnetwork.session.security.Crypto
9-
15+
import org.json4s.{Formats, JValue}
1016
import org.softnetwork.session.model.Session
1117

12-
import scala.collection.mutable
18+
import scala.language.implicitConversions
1319
import scala.util.Try
1420

1521
/** Created by smanciot on 29/04/2021.
1622
*/
1723
trait SessionData extends ProtobufDomainObject
1824

19-
trait SessionDecorator { _: Session =>
25+
trait SessionDecorator { self: Session =>
2026

2127
import Session._
2228

2329
private var dirty: Boolean = false
2430

2531
def clear(): Session = {
2632
val theId = id
27-
data.clear()
28-
this += CookieName -> theId
33+
withKvs(Map.empty[String, String] + (SessionName -> theId))
2934
}
3035

3136
def isDirty: Boolean = dirty
3237

33-
def get(key: String): Option[String] = data.get(key)
38+
def get(key: String): Option[String] = kvs.get(key)
3439

35-
def isEmpty: Boolean = data.isEmpty
40+
def isEmpty: Boolean = kvs.isEmpty
3641

37-
def contains(key: String): Boolean = data.contains(key)
42+
def contains(key: String): Boolean = kvs.contains(key)
3843

39-
def -=(key: String): Session = synchronized {
40-
dirty = true
41-
data -= key
42-
this
43-
}
44+
def -(key: String): Session =
45+
synchronized {
46+
dirty = true
47+
withKvs(kvs - key)
48+
}
4449

45-
def +=(kv: (String, String)): Session = synchronized {
46-
dirty = true
47-
data += kv
48-
this
49-
}
50+
def +(kv: (String, String)): Session =
51+
synchronized {
52+
dirty = true
53+
withKvs(kvs + kv)
54+
}
5055

51-
def apply(key: String): Any = data(key)
56+
def ++(kvs: Seq[(String, String)]): Session =
57+
synchronized {
58+
dirty = true
59+
withKvs(this.kvs ++ kvs)
60+
}
5261

53-
lazy val id: String = data(CookieName)
62+
def apply(key: String): Any = kvs(key)
63+
64+
lazy val id: String = kvs(SessionName)
5465

5566
lazy val admin: Boolean = get(adminKey).exists(_.toBoolean)
5667

@@ -66,67 +77,95 @@ trait SessionCompanion {
6677

6778
val anonymousKey = "anonymous"
6879

69-
val sessionConfig: SessionConfig = {
70-
SessionConfig.default(CookieSecret)
71-
}
80+
val SessionName: String = DefaultSessionConfig.sessionCookieConfig.name
7281

73-
implicit val sessionManager: SessionManager[Session] = new SessionManager[Session](sessionConfig)
82+
val _refreshable: Boolean = Continuity match {
83+
case "refreshable" => true
84+
case _ => false
85+
}
7486

7587
def apply(): Session =
7688
Session.defaultInstance
77-
.withData(mutable.Map(CookieName -> UUID.randomUUID.toString))
78-
.withRefreshable(false)
89+
.withKvs(Map(SessionName -> UUID.randomUUID.toString))
90+
.withRefreshable(_refreshable)
7991

8092
def apply(uuid: String): Session =
8193
Session.defaultInstance
82-
.withData(mutable.Map(CookieName -> uuid))
83-
.withRefreshable(false)
84-
85-
implicit def sessionSerializer: SessionSerializer[Session, String] =
86-
new SessionSerializer[Session, String] {
87-
override def serialize(session: Session): String = {
88-
val encoded = java.net.URLEncoder
89-
.encode(
90-
session.data
91-
.filterNot(_._1.contains(":"))
92-
.map(d => d._1 + ":" + d._2)
93-
.mkString("\u0000"),
94-
"UTF-8"
95-
)
96-
Crypto.sign(encoded, CookieSecret) + "-" + encoded
97-
}
94+
.withKvs(Map(SessionName -> uuid))
95+
.withRefreshable(_refreshable)
9896

99-
override def deserialize(data: String): Try[Session] = {
100-
def urldecode(data: String) =
101-
mutable.Map[String, String](
102-
java.net.URLDecoder
103-
.decode(data, "UTF-8")
104-
.split("\u0000")
105-
.map(_.split(":"))
106-
.map(p => p(0) -> p.drop(1).mkString(":")): _*
107-
)
108-
// Do not change this unless you understand the security issues behind timing attacks.
109-
// This method intentionally runs in constant time if the two strings have the same length.
110-
// If it didn't, it would be vulnerable to a timing attack.
111-
def safeEquals(a: String, b: String) = {
112-
if (a.length != b.length) false
113-
else {
114-
var equal = 0
115-
for (i <- Array.range(0, a.length)) {
116-
equal |= a(i) ^ b(i)
117-
}
118-
equal == 0
119-
}
120-
}
97+
}
12198

122-
Try {
123-
val splitted = data.split("-")
124-
val message = splitted.tail.mkString("-")
125-
if (safeEquals(splitted(0), Crypto.sign(message, CookieSecret)))
126-
Session.defaultInstance.withData(urldecode(message))
127-
else
128-
throw new Exception("corrupted encrypted data")
99+
class BasicSessionSerializer(val serverSecret: String) extends SessionSerializer[Session, String] {
100+
101+
override def serialize(session: Session): String = {
102+
val encoded = java.net.URLEncoder
103+
.encode(
104+
session.kvs
105+
.filterNot(_._1.contains(":"))
106+
.map(d => d._1 + ":" + d._2)
107+
.mkString("\u0000"),
108+
"UTF-8"
109+
)
110+
Crypto.sign(encoded, serverSecret) + "-" + encoded
111+
}
112+
113+
override def deserialize(kvs: String): Try[Session] = {
114+
def urldecode(kvs: String) =
115+
Map[String, String](
116+
java.net.URLDecoder
117+
.decode(kvs, "UTF-8")
118+
.split("\u0000")
119+
.map(_.split(":"))
120+
.map(p => p(0) -> p.drop(1).mkString(":")): _*
121+
)
122+
123+
// Do not change this unless you understand the security issues behind timing attacks.
124+
// This method intentionally runs in constant time if the two strings have the same length.
125+
// If it didn't, it would be vulnerable to a timing attack.
126+
def safeEquals(a: String, b: String) = {
127+
if (a.length != b.length) false
128+
else {
129+
var equal = 0
130+
for (i <- Array.range(0, a.length)) {
131+
equal |= a(i) ^ b(i)
129132
}
133+
equal == 0
130134
}
131135
}
136+
137+
Try {
138+
val split = kvs.split("-")
139+
val message = split.tail.mkString("-")
140+
if (safeEquals(split(0), Crypto.sign(message, serverSecret)))
141+
Session.defaultInstance.withKvs(urldecode(message))
142+
else
143+
throw new Exception("corrupted encrypted kvs")
144+
}
145+
}
146+
}
147+
148+
object SessionSerializers {
149+
150+
def basic(secret: String): SessionSerializer[Session, String] =
151+
new BasicSessionSerializer(secret)
152+
153+
def jwt(implicit formats: Formats): SessionSerializer[Session, JValue] =
154+
JValueSessionSerializer.caseClass[Session]
155+
}
156+
157+
object SessionManagers {
158+
159+
def basic(implicit sessionConfig: SessionConfig): SessionManager[Session] = {
160+
import SessionEncoder._
161+
implicit val serializer: SessionSerializer[Session, String] =
162+
SessionSerializers.basic(sessionConfig.serverSecret)
163+
new SessionManager[Session](sessionConfig)
164+
}
165+
166+
def jwt(implicit sessionConfig: SessionConfig, formats: Formats): SessionManager[Session] = {
167+
implicit val serializer: SessionSerializer[Session, JValue] = SessionSerializers.jwt(formats)
168+
implicit val encoder: JwtSessionEncoder[Session] = new JwtSessionEncoder[Session]
169+
new SessionManager[Session](sessionConfig)(encoder)
170+
}
132171
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package app.softnetwork.session.model
2+
3+
import app.softnetwork.serialization.commonFormats
4+
import app.softnetwork.session.config.Settings.Session.DefaultSessionConfig
5+
import com.softwaremill.session.{
6+
JwtSessionEncoder,
7+
SessionConfig,
8+
SessionEncoder,
9+
SessionSerializer
10+
}
11+
import org.json4s.{Formats, JValue}
12+
import org.scalatest.Assertion
13+
import org.scalatest.wordspec.AnyWordSpecLike
14+
import org.softnetwork.session.model.Session
15+
16+
class SessionEncodersSpec extends AnyWordSpecLike {
17+
18+
implicit def sessionConfig: SessionConfig = DefaultSessionConfig
19+
20+
implicit def formats: Formats = commonFormats
21+
22+
val session: Session = Session.defaultInstance.withKvs(
23+
Map(
24+
"_sessiondata" -> "id",
25+
"profile" -> "profile",
26+
"admin" -> "true"
27+
)
28+
)
29+
30+
val now: Long = System.currentTimeMillis()
31+
32+
var result: String = _
33+
34+
"basic session encoder" must {
35+
"encode" in {
36+
implicit val serializer: SessionSerializer[Session, String] =
37+
SessionSerializers.basic(sessionConfig.serverSecret)
38+
result = SessionEncoder.basic.encode(session, now, sessionConfig)
39+
}
40+
"decode" in {
41+
implicit val serializer: SessionSerializer[Session, String] =
42+
SessionSerializers.basic(sessionConfig.serverSecret)
43+
SessionEncoder.basic[Session].decode(result, sessionConfig).toOption match {
44+
case Some(r) => check(Some(r.t))
45+
case _ => fail("unable to decode session")
46+
}
47+
}
48+
}
49+
50+
"jwt session encoder" must {
51+
"encode" in {
52+
implicit val serializer: SessionSerializer[Session, JValue] = SessionSerializers.jwt(formats)
53+
result = new JwtSessionEncoder[Session].encode(session, now, sessionConfig)
54+
}
55+
"decode" in {
56+
implicit val serializer: SessionSerializer[Session, JValue] = SessionSerializers.jwt(formats)
57+
new JwtSessionEncoder[Session].decode(result, sessionConfig).toOption match {
58+
case Some(r) => check(Some(r.t))
59+
case _ => fail("unable to decode session")
60+
}
61+
}
62+
}
63+
64+
def check(maybeSession: Option[Session]): Assertion = {
65+
assert(maybeSession.isDefined)
66+
assert(maybeSession.get.id == session.id)
67+
assert(maybeSession.get.profile.getOrElse("") == session.profile.getOrElse(""))
68+
assert(maybeSession.get.admin == session.admin)
69+
}
70+
}

0 commit comments

Comments
 (0)