forked from playframework/playframework
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Cookie.scala
858 lines (740 loc) · 27.3 KB
/
Cookie.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
/*
* Copyright (C) Lightbend Inc. <https://www.lightbend.com>
*/
package play.api.mvc
import com.fasterxml.jackson.databind.ObjectMapper
import java.net.URLDecoder
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.jackson.io.JacksonDeserializer
import io.jsonwebtoken.jackson.io.JacksonSerializer
import play.api.MarkerContexts.SecurityMarkerContext
import play.api._
import play.api.http._
import play.api.inject.SimpleModule
import play.api.inject.bind
import play.api.libs.crypto.CookieSigner
import play.api.libs.crypto.CookieSignerProvider
import play.api.mvc.Cookie.SameSite
import play.libs.Scala
import play.mvc.Http.{ Cookie => JCookie }
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
import scala.collection.immutable.ListMap
import scala.util.Try
import scala.util.control.NonFatal
/**
* An HTTP cookie.
*
* @param name the cookie name
* @param value the cookie value
* @param maxAge the cookie expiration date in seconds, `None` for a transient cookie, or a value 0 or less to expire a cookie now
* @param path the cookie path, defaulting to the root path `/`
* @param domain the cookie domain
* @param secure whether this cookie is secured, sent only for HTTPS requests
* @param httpOnly whether this cookie is HTTP only, i.e. not accessible from client-side JavaScript code
* @param sameSite defines cookie access restriction: first-party or same-site context
*/
case class Cookie(
name: String,
value: String,
maxAge: Option[Int] = None,
path: String = "/",
domain: Option[String] = None,
secure: Boolean = false,
httpOnly: Boolean = true,
sameSite: Option[Cookie.SameSite] = None
) {
lazy val asJava = {
new JCookie(
name,
value,
maxAge.map(i => Integer.valueOf(i)).orNull,
path,
domain.orNull,
secure,
httpOnly,
sameSite.map(_.asJava).orNull
)
}
}
object Cookie {
private val logger = Logger(this.getClass)
sealed abstract class SameSite(val value: String) {
private def matches(v: String): Boolean = value.equalsIgnoreCase(v)
def asJava: play.mvc.Http.Cookie.SameSite = play.mvc.Http.Cookie.SameSite.parse(value).get
}
object SameSite {
private[play] val values: Seq[SameSite] = Seq(Strict, Lax, None)
def parse(value: String): Option[SameSite] = values.find(_.matches(value))
case object Strict extends SameSite("Strict")
case object Lax extends SameSite("Lax")
case object None extends SameSite("None")
}
/**
* Check the prefix of this cookie and make sure it matches the rules.
*
* @return the original cookie if it is valid, else a new cookie that has the proper attributes set.
*/
def validatePrefix(cookie: Cookie): Cookie = {
val SecurePrefix = "__Secure-"
val HostPrefix = "__Host-"
@inline def warnIfNotSecure(prefix: String): Unit = {
if (!cookie.secure) {
logger.warn(s"$prefix prefix is used for cookie but Secure flag not set! Setting now. Cookie is: $cookie")(
SecurityMarkerContext
)
}
}
if (cookie.name.startsWith(SecurePrefix)) {
warnIfNotSecure(SecurePrefix)
cookie.copy(secure = true)
} else if (cookie.name.startsWith(HostPrefix)) {
warnIfNotSecure(HostPrefix)
if (cookie.path != "/") {
logger.warn(s"""$HostPrefix is used on cookie but Path is not "/"! Setting now. Cookie is: $cookie""")(
SecurityMarkerContext
)
}
cookie.copy(secure = true, path = "/")
} else {
cookie
}
}
/**
* The cookie's Max-Age, in seconds, when we expire the cookie.
*
* When Max-Age = 0, Expires is set to 0 epoch time for compatibility with older browsers.
*/
val DiscardedMaxAge: Int = 0
}
/**
* A cookie to be discarded. This contains only the data necessary for discarding a cookie.
*
* @param name the name of the cookie to discard
* @param path the path of the cookie, defaults to the root path
* @param domain the cookie domain
* @param secure whether this cookie is secured
*/
case class DiscardingCookie(
name: String,
path: String = "/",
domain: Option[String] = None,
secure: Boolean = false,
sameSite: Option[SameSite] = None
) {
def toCookie = Cookie(name, "", Some(Cookie.DiscardedMaxAge), path, domain, secure, false, sameSite)
}
/**
* The HTTP cookies set.
*/
trait Cookies extends Traversable[Cookie] {
/**
* Optionally returns the cookie associated with a key.
*/
def get(name: String): Option[Cookie]
/**
* Retrieves the cookie that is associated with the given key.
*/
def apply(name: String): Cookie = get(name).getOrElse(scala.sys.error("Cookie doesn't exist"))
}
/**
* Helper utilities to encode Cookies.
*/
object Cookies extends CookieHeaderEncoding {
// Use global state for cookie header configuration
@deprecated("Inject play.api.mvc.CookieHeaderEncoding instead", "2.6.0")
protected override def config: CookiesConfiguration = HttpConfiguration.current.cookies
@deprecated("Inject play.api.mvc.CookieHeaderEncoding instead", "2.8.1")
override def fromSetCookieHeader(header: Option[String]): Cookies =
super.fromSetCookieHeader(header)
@deprecated("Inject play.api.mvc.CookieHeaderEncoding instead", "2.8.1")
override def encodeSetCookieHeader(cookies: Seq[Cookie]): String =
super.encodeSetCookieHeader(cookies)
@deprecated("Inject play.api.mvc.CookieHeaderEncoding instead", "2.8.1")
override def encodeCookieHeader(cookies: Seq[Cookie]): String =
super.encodeCookieHeader(cookies)
@deprecated("Inject play.api.mvc.CookieHeaderEncoding instead", "2.8.1")
override def decodeSetCookieHeader(cookieHeader: String): Seq[Cookie] =
super.decodeSetCookieHeader(cookieHeader)
@deprecated("Inject play.api.mvc.CookieHeaderEncoding instead", "2.8.1")
override def decodeCookieHeader(cookieHeader: String): Seq[Cookie] =
super.decodeCookieHeader(cookieHeader)
@deprecated("Inject play.api.mvc.CookieHeaderEncoding instead", "2.8.1")
override def mergeSetCookieHeader(cookieHeader: String, cookies: Seq[Cookie]): String =
super.mergeSetCookieHeader(cookieHeader, cookies)
@deprecated("Inject play.api.mvc.CookieHeaderEncoding instead", "2.8.1")
override def mergeCookieHeader(cookieHeader: String, cookies: Seq[Cookie]): String =
super.mergeCookieHeader(cookieHeader, cookies)
def apply(cookies: Seq[Cookie]): Cookies = new Cookies {
lazy val cookiesByName = cookies.groupBy(_.name).mapValues(_.head)
override def get(name: String) = cookiesByName.get(name)
override def foreach[U](f: Cookie => U) = cookies.foreach(f)
def iterator: Iterator[Cookie] = cookies.iterator
}
}
/**
* Logic for encoding and decoding `Cookie` and `Set-Cookie` headers.
*/
trait CookieHeaderEncoding {
import play.core.cookie.encoding.DefaultCookie
private implicit val markerContext = SecurityMarkerContext
protected def config: CookiesConfiguration
/**
* Play doesn't support multiple values per header, so has to compress cookies into one header. The problem is,
* Set-Cookie doesn't support being compressed into one header, the reason being that the separator character for
* header values, comma, is used in the dates in the Expires attribute of a cookie value. So we synthesise our own
* separator, that we use here, and before we send the cookie back to the client.
*/
val SetCookieHeaderSeparator = ";;"
val SetCookieHeaderSeparatorRegex = SetCookieHeaderSeparator.r
import scala.collection.JavaConverters._
// We use netty here but just as an API to handle cookies encoding
private val logger = Logger(this.getClass)
def fromSetCookieHeader(header: Option[String]): Cookies = header match {
case Some(headerValue) =>
fromMap(
decodeSetCookieHeader(headerValue)
.groupBy(_.name)
.mapValues(_.head)
.toMap
)
case None => fromMap(Map.empty)
}
def fromCookieHeader(header: Option[String]): Cookies = header match {
case Some(headerValue) =>
fromMap(
decodeCookieHeader(headerValue)
.groupBy(_.name)
.mapValues(_.head)
.toMap
)
case None => fromMap(Map.empty)
}
private def fromMap(cookies: Map[String, Cookie]): Cookies = new Cookies {
def get(name: String) = cookies.get(name)
override def toString = cookies.toString
override def foreach[U](f: (Cookie) => U): Unit = {
cookies.values.foreach(f)
}
def iterator: Iterator[Cookie] = cookies.valuesIterator
}
/**
* Encodes cookies as a Set-Cookie HTTP header.
*
* @param cookies the Cookies to encode
* @return a valid Set-Cookie header value
*/
def encodeSetCookieHeader(cookies: Seq[Cookie]): String = {
val encoder = config.serverEncoder
val newCookies = cookies.map { cookie =>
val c = Cookie.validatePrefix(cookie)
val nc = new DefaultCookie(c.name, c.value)
nc.setMaxAge(c.maxAge.getOrElse(Integer.MIN_VALUE))
nc.setPath(c.path)
c.domain.foreach(nc.setDomain)
nc.setSecure(c.secure)
nc.setHttpOnly(c.httpOnly)
nc.setSameSite(c.sameSite.map(_.value).orNull)
encoder.encode(nc)
}
newCookies.mkString(SetCookieHeaderSeparator)
}
/**
* Encodes cookies as a Set-Cookie HTTP header.
*
* @param cookies the Cookies to encode
* @return a valid Set-Cookie header value
*/
def encodeCookieHeader(cookies: Seq[Cookie]): String = {
val encoder = config.clientEncoder
encoder.encode(cookies.map { cookie =>
new DefaultCookie(cookie.name, cookie.value)
}.asJava)
}
/**
* Decodes a Set-Cookie header value as a proper cookie set.
*
* @param cookieHeader the Set-Cookie header value
* @return decoded cookies
*/
def decodeSetCookieHeader(cookieHeader: String): Seq[Cookie] = {
if (cookieHeader.isEmpty) {
// fail fast if there are no existing cookies
Seq.empty
} else {
Try {
val decoder = config.clientDecoder
val newCookies = for {
cookieString <- SetCookieHeaderSeparatorRegex.split(cookieHeader).toSeq
cookie <- Option(decoder.decode(cookieString.trim))
} yield Cookie(
cookie.name,
cookie.value,
if (cookie.maxAge == Integer.MIN_VALUE) None else Some(cookie.maxAge),
Option(cookie.path).getOrElse("/"),
Option(cookie.domain),
cookie.isSecure,
cookie.isHttpOnly,
Option(cookie.sameSite).flatMap(SameSite.parse)
)
newCookies.map(Cookie.validatePrefix)
}.getOrElse {
logger.debug(s"Couldn't decode the Cookie header containing: $cookieHeader")
Seq.empty
}
}
}
/**
* Decodes a Cookie header value as a proper cookie set.
*
* @param cookieHeader the Cookie header value
* @return decoded cookies
*/
def decodeCookieHeader(cookieHeader: String): Seq[Cookie] = {
Try {
config.serverDecoder
.decode(cookieHeader)
.asScala
.map(cookie => Cookie(cookie.name, cookie.value))
.toSeq
}.getOrElse {
logger.debug(s"Couldn't decode the Cookie header containing: $cookieHeader")
Nil
}
}
/**
* Merges an existing Set-Cookie header with new cookie values
*
* @param cookieHeader the existing Set-Cookie header value
* @param cookies the new cookies to encode
* @return a valid Set-Cookie header value
*/
def mergeSetCookieHeader(cookieHeader: String, cookies: Seq[Cookie]): String = {
val rawCookies = decodeSetCookieHeader(cookieHeader) ++ cookies
val mergedCookies: Seq[Cookie] = CookieHeaderMerging.mergeSetCookieHeaderCookies(rawCookies)
encodeSetCookieHeader(mergedCookies)
}
/**
* Merges an existing Cookie header with new cookie values
*
* @param cookieHeader the existing Cookie header value
* @param cookies the new cookies to encode
* @return a valid Cookie header value
*/
def mergeCookieHeader(cookieHeader: String, cookies: Seq[Cookie]): String = {
val rawCookies = decodeCookieHeader(cookieHeader) ++ cookies
val mergedCookies: Seq[Cookie] = CookieHeaderMerging.mergeCookieHeaderCookies(rawCookies)
encodeCookieHeader(mergedCookies)
}
}
/**
* The default implementation of `CookieHeaders`.
*/
class DefaultCookieHeaderEncoding @Inject() (
protected override val config: CookiesConfiguration = CookiesConfiguration()
) extends CookieHeaderEncoding
/**
* Utilities for merging individual cookie values in HTTP cookie headers.
*/
object CookieHeaderMerging {
/**
* Merge the elements in a sequence so that there is only one occurrence of
* elements when mapped by a discriminator function.
*/
private def mergeOn[A, B](input: Traversable[A], f: A => B): Seq[A] = {
val withMergeValue: Seq[(B, A)] = input.toSeq.map(el => (f(el), el))
ListMap(withMergeValue: _*).values.toSeq
}
/**
* Merges the cookies contained in a `Set-Cookie` header so that there's
* only one cookie for each name/path/domain triple.
*/
def mergeSetCookieHeaderCookies(unmerged: Traversable[Cookie]): Seq[Cookie] = {
// See rfc6265#section-4.1.2
// Secure and http-only attributes are not considered when testing if
// two cookies are overlapping.
mergeOn(unmerged, (c: Cookie) => (c.name, c.path, c.domain.map(_.toLowerCase(Locale.ENGLISH))))
}
/**
* Merges the cookies contained in a `Cookie` header so that there's
* only one cookie for each name.
*/
def mergeCookieHeaderCookies(unmerged: Traversable[Cookie]): Seq[Cookie] = {
mergeOn(unmerged, (c: Cookie) => c.name)
}
}
/**
* Trait that should be extended by the Cookie helpers.
*/
trait CookieBaker[T <: AnyRef] { self: CookieDataCodec =>
/**
* The cookie name.
*/
def COOKIE_NAME: String
/**
* Default cookie, returned in case of error or if missing in the HTTP headers.
*/
def emptyCookie: T
/**
* `true` if the Cookie is signed. Defaults to false.
*/
def isSigned: Boolean = false
/**
* `true` if the Cookie should have the httpOnly flag, disabling access from Javascript. Defaults to true.
*/
def httpOnly = true
/**
* The cookie expiration date in seconds, `None` for a transient cookie
*/
def maxAge: Option[Int] = None
/**
* The cookie domain. Defaults to None.
*/
def domain: Option[String] = None
/**
* `true` if the Cookie should have the secure flag, restricting usage to https. Defaults to false.
*/
def secure = false
/**
* The cookie path.
*/
def path: String = "/"
/**
* The value of the SameSite attribute of the cookie. Defaults to no SameSite.
*/
def sameSite: Option[Cookie.SameSite] = None
/**
* Encodes the data as a `Cookie`.
*/
def encodeAsCookie(data: T): Cookie = {
val cookie = encode(serialize(data))
Cookie(COOKIE_NAME, cookie, maxAge, path, domain, secure, httpOnly, sameSite)
}
/**
* Decodes the data from a `Cookie`.
*/
def decodeCookieToMap(cookie: Option[Cookie]): Map[String, String] = {
serialize(decodeFromCookie(cookie))
}
/**
* Decodes the data from a `Cookie`.
*/
def decodeFromCookie(cookie: Option[Cookie]): T =
if (cookie.isEmpty) emptyCookie
else {
val extractedCookie: Cookie = cookie.get
if (extractedCookie.name != COOKIE_NAME) emptyCookie /* can this happen? */
else {
deserialize(decode(extractedCookie.value))
}
}
def discard = DiscardingCookie(COOKIE_NAME, path, domain, secure, sameSite)
/**
* Builds the cookie object from the given data map.
*
* @param data the data map to build the cookie object
* @return a new cookie object
*/
protected def deserialize(data: Map[String, String]): T
/**
* Converts the given cookie object into a data map.
*
* @param cookie the cookie object to serialize into a map
* @return a new `Map` storing the key-value pairs for the given cookie
*/
protected def serialize(cookie: T): Map[String, String]
}
/**
* This trait encodes and decodes data to a string used as cookie value.
*/
trait CookieDataCodec {
/**
* Encodes the data as a `String`.
*/
def encode(data: Map[String, String]): String
/**
* Decodes from an encoded `String`.
*/
def decode(data: String): Map[String, String]
}
/**
* This trait writes out cookies as url encoded safe text format, optionally prefixed with a
* signed code.
*/
trait UrlEncodedCookieDataCodec extends CookieDataCodec {
private val logger = Logger(this.getClass)
/**
* The cookie signer.
*/
def cookieSigner: CookieSigner
def isSigned: Boolean
/**
* Encodes the data as a `String`.
*/
def encode(data: Map[String, String]): String = {
val encoded = data
.map { case (k, v) => URLEncoder.encode(k, "UTF-8") + "=" + URLEncoder.encode(v, "UTF-8") }
.mkString("&")
if (isSigned)
cookieSigner.sign(encoded) + "-" + encoded
else
encoded
}
/**
* Decodes from an encoded `String`.
*/
def decode(data: String): Map[String, String] = {
def urldecode(data: String): Map[String, String] = {
// In some cases we've seen clients ignore the Max-Age and Expires on a cookie, and fail to properly clear the
// cookie. This can cause the client to send an empty cookie back to us after we've attempted to clear it. So
// just decode empty cookies to an empty map. See https://github.com/playframework/playframework/issues/7680.
if (data.isEmpty) {
Map.empty[String, String]
} else {
data
.split("&")
.iterator
.flatMap { pair =>
pair.span(_ != '=') match { // "foo=bar".span(_ != '=') -> (foo,=bar)
case (_, "") => // Skip invalid
Option.empty[(String, String)]
case (encName, encVal) =>
Some(URLDecoder.decode(encName, "UTF-8") -> URLDecoder.decode(encVal.tail, "UTF-8"))
}
}
.toMap
}
}
// 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 safeEquals(a: String, b: String) = {
if (a.length != b.length) {
false
} else {
var equal = 0
for (i <- Array.range(0, a.length)) {
equal |= a(i) ^ b(i)
}
equal == 0
}
}
try {
if (isSigned) {
val parts = data.split("-", 2)
val message = parts.tail.mkString("-")
if (safeEquals(parts(0), cookieSigner.sign(message))) {
urldecode(message)
} else {
logger.warn("Cookie failed message authentication check")(SecurityMarkerContext)
Map.empty[String, String]
}
} else urldecode(data)
} catch {
// fail gracefully is the session cookie is corrupted
case NonFatal(e) =>
logger.warn("Could not decode cookie", e)(SecurityMarkerContext)
Map.empty[String, String]
}
}
}
/**
* JWT cookie encoding and decoding functionality
*/
trait JWTCookieDataCodec extends CookieDataCodec {
private val logger = play.api.Logger(getClass)
def secretConfiguration: SecretConfiguration
def jwtConfiguration: JWTConfiguration
private lazy val formatter = new JWTCookieDataCodec.JWTFormatter(secretConfiguration, jwtConfiguration, clock)
/**
* Encodes the data as a `String`.
*/
override def encode(data: Map[String, String]): String = {
val dataMap = Map(jwtConfiguration.dataClaim -> Jwts.claims(Scala.asJava(data)))
formatter.format(dataMap)
}
/**
* Decodes from an encoded `String`.
*/
override def decode(encodedString: String): Map[String, String] = {
import io.jsonwebtoken._
import scala.collection.JavaConverters._
try {
// Get all the claims
val claimMap = formatter.parse(encodedString)
// Pull out the JWT data claim and only return that.
val data = claimMap(jwtConfiguration.dataClaim).asInstanceOf[java.util.Map[String, AnyRef]]
data.asScala.mapValues { v =>
v.toString
}.toMap
} catch {
case e: IllegalStateException =>
// Used in the case where the header algorithm does not match.
logger.error(e.getMessage)
Map.empty
// We want to warn specifically about premature and expired JWT,
// because they depend on clock skew and can cause silent user error
// if production servers get out of sync
case e: PrematureJwtException =>
val id = e.getClaims.getId
logger.warn(s"decode: premature JWT found! id = $id, message = ${e.getMessage}")(SecurityMarkerContext)
Map.empty
case e: ExpiredJwtException =>
val id = e.getClaims.getId
logger.warn(s"decode: expired JWT found! id = $id, message = ${e.getMessage}")(SecurityMarkerContext)
Map.empty
case e: security.SignatureException =>
// Thrown when an invalid cookie signature is found -- this can be confusing to end users
// so give a special logging message to indicate problem.
logger.warn(s"decode: cookie has invalid signature! message = ${e.getMessage}")(SecurityMarkerContext)
val devLogger = logger.forMode(Mode.Dev)
devLogger.info(
"The JWT signature in the cookie does not match the locally computed signature with the server. "
+ "This usually indicates the browser has a leftover cookie from another Play application, so clearing "
+ "cookies may resolve this error message."
)
Map.empty
case NonFatal(e) =>
logger.warn(s"decode: could not decode JWT: ${e.getMessage}", e)(SecurityMarkerContext)
Map.empty
}
}
/** The unique id of the JWT, if any. */
protected def uniqueId(): Option[String] = Some(JWTCookieDataCodec.JWTIDGenerator.generateId())
/** The clock used for checking expires / not before code */
protected def clock: java.time.Clock = java.time.Clock.systemUTC()
}
object JWTCookieDataCodec {
private val objectMapper: ObjectMapper = new ObjectMapper()
/**
* Maps to and from JWT claims. This class is more basic than the JWT
* cookie signing, because it exposes all claims, not just the "data" ones.
*
* @param secretConfiguration the secret used for signing JWT
* @param jwtConfiguration the configuration for JWT
* @param clock the system clock
*/
private[play] class JWTFormatter(
secretConfiguration: SecretConfiguration,
jwtConfiguration: JWTConfiguration,
clock: java.time.Clock
) {
import io.jsonwebtoken._
import scala.collection.JavaConverters._
private val jwtClock = new Clock {
override def now(): Date = java.util.Date.from(clock.instant())
}
private val signatureAlgorithm = SignatureAlgorithm.forName(jwtConfiguration.signatureAlgorithm)
private val secretKey: SecretKey = new SecretKeySpec(
secretConfiguration.secret.getBytes(StandardCharsets.UTF_8),
signatureAlgorithm.getJcaName
)
private val jwtParser: JwtParser = Jwts
.parserBuilder()
.setClock(jwtClock)
.setSigningKey(secretKey)
.setAllowedClockSkewSeconds(jwtConfiguration.clockSkew.toSeconds)
.deserializeJsonWith(new JacksonDeserializer(objectMapper))
.build()
/**
* Parses encoded JWT against configuration, returns all JWT claims.
*
* @param encodedString the signed and encoded JWT.
* @return the map of claims
*/
def parse(encodedString: String): Map[String, AnyRef] = {
val jws: Jws[Claims] = jwtParser.parseClaimsJws(encodedString)
val headerAlgorithm = jws.getHeader.getAlgorithm
if (headerAlgorithm != jwtConfiguration.signatureAlgorithm) {
val id = jws.getBody.getId
val msg = s"Invalid header algorithm $headerAlgorithm in JWT $id"
throw new IllegalStateException(msg)
}
jws.getBody.asScala.toMap
}
/**
* Formats the input claims to a JWT string, and adds extra date related claims.
*
* @param claims all the claims to be added to JWT.
* @return the signed, encoded JWT with extra date related claims
*/
def format(claims: Map[String, AnyRef]): String = {
val builder = Jwts.builder().serializeToJsonWith(new JacksonSerializer(objectMapper))
val now = jwtClock.now()
// Add the claims one at a time because it saves problems with mutable maps
// under the implementation...
claims.foreach {
case (k, v) =>
builder.claim(k, v)
}
// https://tools.ietf.org/html/rfc7519#section-4.1.4
jwtConfiguration.expiresAfter.map { duration =>
val expirationDate = new Date(now.getTime + duration.toMillis)
builder.setExpiration(expirationDate)
}
builder.setNotBefore(now) // https://tools.ietf.org/html/rfc7519#section-4.1.5
builder.setIssuedAt(now) // https://tools.ietf.org/html/rfc7519#section-4.1.6
// Sign and compact into a string...
// Even though secretKey already knows about the algorithm we have to pass signatureAlgorithm separately as well again.
// If not passing it, JJWT would try to determine the algorithm from the secretKey bit length via SignatureAlgorithm.forSigningKey(...)
// That would be a problem when e.g. in app conf HS256 is set (the default), but the secret has >= 64 bytes, then JJWT would choose HS512.
builder.signWith(secretKey, signatureAlgorithm).compact()
}
}
/** Utility object to generate random nonces for JWT from SecureRandom */
private[play] object JWTIDGenerator {
private val sr = new java.security.SecureRandom()
def generateId(): String = {
new java.math.BigInteger(130, sr).toString(32)
}
}
}
/**
* A trait that identifies the cookie encoding and uses the appropriate codec, for
* upgrading from a signed cookie encoding to a JWT cookie encoding.
*/
trait FallbackCookieDataCodec extends CookieDataCodec {
def jwtCodec: JWTCookieDataCodec
def signedCodec: UrlEncodedCookieDataCodec
def encode(data: Map[String, String]): String = jwtCodec.encode(data)
def decode(encodedData: String): Map[String, String] = {
// Per https://github.com/playframework/playframework/pull/7053#issuecomment-285220730
val codec = encodedData match {
case s if s.contains('=') => signedCodec // It's a legacy session with at least one value.
case s if s.contains('.') => jwtCodec // It's a JWT session.
case _ => signedCodec // It's an empty legacy session.
}
codec.decode(encodedData)
}
}
case class DefaultUrlEncodedCookieDataCodec(
isSigned: Boolean,
cookieSigner: CookieSigner
) extends UrlEncodedCookieDataCodec
case class DefaultJWTCookieDataCodec @Inject() (
secretConfiguration: SecretConfiguration,
jwtConfiguration: JWTConfiguration
) extends JWTCookieDataCodec
/**
* A cookie module that uses JWT as the cookie encoding, falling back to URL encoding.
*/
class CookiesModule
extends SimpleModule(
bind[CookieSigner].toProvider[CookieSignerProvider],
bind[SessionCookieBaker].to[DefaultSessionCookieBaker],
bind[FlashCookieBaker].to[DefaultFlashCookieBaker]
)
/**
* A cookie module that uses the urlencoded cookie encoding.
*/
class LegacyCookiesModule
extends SimpleModule(
bind[CookieSigner].toProvider[CookieSignerProvider],
bind[SessionCookieBaker].to[LegacySessionCookieBaker],
bind[FlashCookieBaker].to[LegacyFlashCookieBaker],
)