Skip to content

Commit f475470

Browse files
committed
update SessionData and SessionMaterials, add JwtClaims
1 parent 975f9ca commit f475470

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+894
-355
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.5.1"
11+
ThisBuild / version := "0.6.0"
1212

1313
ThisBuild / scalaVersion := "2.12.18"
1414

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
syntax = "proto3";
22

33
import "scalapb/scalapb.proto";
4+
import "google/protobuf/wrappers.proto";
45

56
package org.softnetwork.session.model;
67

@@ -25,4 +26,25 @@ message Session {
2526
option (scalapb.message).companion_extends = "SessionCompanion";
2627
map<string, string> data = 1 [(scalapb.field).map_type="collection.immutable.Map", (scalapb.field).scala_name = "kvs"];
2728
bool refreshable = 2;
28-
}
29+
}
30+
31+
message JwtClaims {
32+
option (scalapb.message).extends = "SessionData";
33+
option (scalapb.message).extends = "JwtClaimsDecorator";
34+
option (scalapb.message).companion_extends = "JwtClaimsCompanion";
35+
map<string, string> data = 1 [(scalapb.field).map_type="collection.immutable.Map", (scalapb.field).scala_name = "additionalClaims"];
36+
bool refreshable = 2;
37+
google.protobuf.StringValue iss = 3;
38+
google.protobuf.StringValue sub = 4;
39+
google.protobuf.StringValue aud = 5;
40+
google.protobuf.Int64Value exp = 6;
41+
google.protobuf.Int64Value nbf = 7;
42+
google.protobuf.Int64Value iat = 8;
43+
google.protobuf.StringValue jti = 9;
44+
}
45+
46+
message ApiKey {
47+
option (scalapb.message).extends = "ProtobufDomainObject";
48+
string clientId = 1;
49+
google.protobuf.StringValue clientSecret = 2;
50+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package app.softnetwork.session.model
2+
3+
import app.softnetwork.session.security.Crypto
4+
import com.softwaremill.session.SessionSerializer
5+
6+
import scala.language.reflectiveCalls
7+
import scala.util.Try
8+
9+
class BasicSessionSerializer[T <: SessionData](val serverSecret: String)(implicit
10+
companion: SessionDataCompanion[T]
11+
) extends SessionSerializer[T, String] {
12+
13+
override def serialize(session: T): String = {
14+
val encoded = java.net.URLEncoder
15+
.encode(
16+
session.data
17+
.filterNot(_._1.contains(":"))
18+
.map(d => d._1 + ":" + d._2)
19+
.mkString("\u0000"),
20+
"UTF-8"
21+
)
22+
Crypto.sign(encoded, serverSecret) + "-" + encoded
23+
}
24+
25+
override def deserialize(r: String): Try[T] = {
26+
def urldecode(data: String): Map[String, String] =
27+
Map[String, String](
28+
java.net.URLDecoder
29+
.decode(data, "UTF-8")
30+
.split("\u0000")
31+
.map(_.split(":"))
32+
.map(p => p(0) -> p.drop(1).mkString(":")): _*
33+
)
34+
35+
// Do not change this unless you understand the security issues behind timing attacks.
36+
// This method intentionally runs in constant time if the two strings have the same length.
37+
// If it didn't, it would be vulnerable to a timing attack.
38+
def safeEquals(a: String, b: String): Boolean = {
39+
if (a.length != b.length) false
40+
else {
41+
var equal = 0
42+
for (i <- Array.range(0, a.length)) {
43+
equal |= a(i) ^ b(i)
44+
}
45+
equal == 0
46+
}
47+
}
48+
49+
Try {
50+
val split = r.split("-")
51+
val message = split.tail.mkString("-")
52+
if (safeEquals(split(0), Crypto.sign(message, serverSecret)))
53+
companion.newSession.withData(urldecode(message))
54+
else
55+
throw new Exception("corrupted encrypted data")
56+
}
57+
}
58+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package app.softnetwork.session.model
2+
3+
import app.softnetwork.persistence.generateUUID
4+
import com.softwaremill.session.{SessionConfig, SessionResult}
5+
import org.json4s._
6+
import org.json4s.jackson.JsonMethods._
7+
import org.softnetwork.session.model.JwtClaims
8+
9+
import java.util.Base64
10+
import scala.util.Try
11+
12+
trait JwtClaimsCompanion extends SessionDataCompanion[JwtClaims] with JwtClaimsKeys {
13+
override def newSession: JwtClaims = JwtClaims.defaultInstance
14+
15+
override def apply(s: String): JwtClaims =
16+
Try {
17+
val sCleaned = if (s.startsWith("Bearer")) s.substring(7).trim else s
18+
val List(_, p, _) = sCleaned.split("\\.").toList
19+
val decodedValue = Try {
20+
parse(new String(Base64.getUrlDecoder.decode(p), "utf-8"))
21+
}
22+
for (jv <- decodedValue) yield {
23+
val iss = jv \\ "iss" match {
24+
case JString(value) => Some(value)
25+
case _ => None
26+
}
27+
val sub = jv \\ "sub" match {
28+
case JString(value) => Some(value)
29+
case _ => None
30+
}
31+
val aud = jv \\ "aud" match {
32+
case JString(value) => Some(value)
33+
case _ => None
34+
}
35+
val exp = jv \\ "exp" match {
36+
case JInt(value) => Some(value.toLong)
37+
case _ => None
38+
}
39+
val nbf = jv \\ "nbf" match {
40+
case JInt(value) => Some(value.toLong)
41+
case _ => None
42+
}
43+
val iat = jv \\ "iat" match {
44+
case JInt(value) => Some(value.toLong)
45+
case _ => None
46+
}
47+
val jti = jv \\ "jti" match {
48+
case JString(value) => Some(value)
49+
case _ => None
50+
}
51+
JwtClaims(
52+
Map.empty,
53+
_refreshable,
54+
iss,
55+
sub,
56+
aud,
57+
exp,
58+
nbf,
59+
iat,
60+
jti
61+
).withId(sub.getOrElse(generateUUID()))
62+
}
63+
}.flatten.toOption.getOrElse(JwtClaims())
64+
65+
def decode(s: String, clientSecret: String): Option[JwtClaims] = {
66+
val config = SessionConfig.default(clientSecret)
67+
JwtClaimsEncoder
68+
.decode(s, config)
69+
.map { dr =>
70+
val expired = config.sessionMaxAgeSeconds.fold(false)(_ =>
71+
System.currentTimeMillis() > dr.expires.getOrElse(Long.MaxValue)
72+
)
73+
if (expired) {
74+
SessionResult.Expired
75+
} else if (!dr.signatureMatches) {
76+
SessionResult.Corrupt(new RuntimeException("Corrupt signature"))
77+
} else if (dr.isLegacy) {
78+
SessionResult.DecodedLegacy(dr.t)
79+
} else {
80+
SessionResult.Decoded(dr.t)
81+
}
82+
}
83+
.recover { case t: Exception => SessionResult.Corrupt(t) }
84+
.get match {
85+
case SessionResult.Decoded(claims) => Some(claims)
86+
case _ => None
87+
}
88+
}
89+
90+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package app.softnetwork.session.model
2+
3+
import com.softwaremill.session.SessionConfig
4+
import org.softnetwork.session.model.JwtClaims
5+
6+
trait JwtClaimsDecorator extends SessionDataDecorator[JwtClaims] with JwtClaimsKeys {
7+
self: JwtClaims =>
8+
9+
lazy val data: Map[String, String] = additionalClaims
10+
11+
override def withData(data: Map[String, String]): JwtClaims = withAdditionalClaims(data)
12+
13+
override def withId(id: String): JwtClaims =
14+
synchronized {
15+
dirty = true
16+
withData(data + (idKey -> id)).withSub(id)
17+
}
18+
19+
override lazy val clientId: Option[String] = iss.orElse(get(clientIdKey))
20+
21+
override def withClientId(clientId: String): JwtClaims =
22+
synchronized {
23+
dirty = true
24+
withData(data + (clientIdKey -> clientId)).withIss(clientId)
25+
}
26+
27+
def encode(clientId: String, clientSecret: String): String = {
28+
JwtClaimsEncoder.encode(
29+
this.withIss(clientId),
30+
System.currentTimeMillis(),
31+
SessionConfig.default(clientSecret)
32+
)
33+
}
34+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package app.softnetwork.session.model
2+
3+
import app.softnetwork.concurrent.Completion
4+
import com.softwaremill.session._
5+
import org.json4s.{DefaultFormats, Formats, JValue}
6+
import org.softnetwork.session.model.{ApiKey, JwtClaims}
7+
8+
import scala.concurrent.Future
9+
import scala.util.Try
10+
11+
trait JwtClaimsEncoder extends SessionEncoder[JwtClaims] with Completion {
12+
13+
implicit def formats: Formats = DefaultFormats
14+
15+
implicit def sessionSerializer: SessionSerializer[JwtClaims, JValue] = SessionSerializers.jwt
16+
17+
def loadApiKey(clientId: String): Future[Option[ApiKey]]
18+
19+
def sessionEncoder = new JwtSessionEncoder[JwtClaims]
20+
21+
override def encode(t: JwtClaims, nowMillis: Long, config: SessionConfig): String = {
22+
val jwt = config.jwt.copy(
23+
issuer = t.iss.orElse(config.jwt.issuer),
24+
subject = t.sub.orElse(config.jwt.subject),
25+
audience = t.aud.orElse(config.jwt.audience)
26+
)
27+
(t.iss match {
28+
case Some(iss) =>
29+
(loadApiKey(iss) complete ()).toOption.flatten
30+
case _ => None
31+
}) match {
32+
case Some(apiKey) if apiKey.clientSecret.isDefined =>
33+
sessionEncoder.encode(
34+
t,
35+
nowMillis,
36+
config.copy(jwt = jwt, serverSecret = apiKey.clientSecret.get)
37+
)
38+
case _ => sessionEncoder.encode(t, nowMillis, config.copy(jwt = jwt))
39+
}
40+
}
41+
42+
override def decode(s: String, config: SessionConfig): Try[DecodeResult[JwtClaims]] = {
43+
val jwtClaims = JwtClaims(s)
44+
val maybeClientId =
45+
if (jwtClaims.iss.contains(config.jwt.issuer.getOrElse(""))) jwtClaims.sub
46+
else jwtClaims.iss
47+
val innerConfig = (maybeClientId match {
48+
case Some(clientId) =>
49+
(loadApiKey(clientId) complete ()).toOption.flatten.flatMap(_.clientSecret)
50+
case _ => None
51+
}) match {
52+
case Some(clientSecret) =>
53+
config.copy(serverSecret = clientSecret)
54+
case _ =>
55+
config
56+
}
57+
sessionEncoder
58+
.decode(s, innerConfig)
59+
.map(result =>
60+
result.copy(t = jwtClaims.copy(additionalClaims = result.t.data ++ jwtClaims.data))
61+
)
62+
63+
}
64+
}
65+
66+
case object JwtClaimsEncoder extends JwtClaimsEncoder {
67+
override def loadApiKey(clientId: String): Future[Option[ApiKey]] = Future.successful(None)
68+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package app.softnetwork.session.model
2+
3+
trait JwtClaimsKeys extends SessionDataKeys {
4+
5+
override lazy val idKey = "id"
6+
7+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package app.softnetwork.session.model
2+
3+
import com.softwaremill.session.SessionSerializer
4+
import org.json4s.jackson.JsonMethods.asJValue
5+
import org.json4s.{DefaultFormats, DefaultWriters, Formats, JValue}
6+
7+
import scala.util.Try
8+
9+
class JwtSessionSerializer[T <: SessionData](implicit companion: SessionDataCompanion[T])
10+
extends SessionSerializer[T, JValue] {
11+
12+
implicit val formats: Formats = DefaultFormats
13+
14+
import DefaultWriters._
15+
16+
override def serialize(t: T): JValue = asJValue(t.data)
17+
18+
override def deserialize(r: JValue): Try[T] = Try {
19+
companion.newSession.withData(r.extract[Map[String, String]])
20+
}
21+
}

0 commit comments

Comments
 (0)