All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.pac4j.http4s.Session.scala Maven / Gradle / Ivy

The newest version!
package org.pac4j.http4s

import cats.Monad

import java.util.Date
import cats.data.{Kleisli, OptionT}
import cats.syntax.applicative._
import cats.syntax.eq._
import cats.syntax.functor._
import cats.syntax.option._
import cats.syntax.traverse._
import cats.effect._
import io.circe.jawn

import javax.crypto.spec.SecretKeySpec
import javax.crypto.{Cipher, Mac}
import org.http4s._
import org.slf4j.LoggerFactory

import java.util.Base64
import org.apache.commons.codec.binary.Hex
import mouse.option._
import org.http4s.server.HttpMiddleware
import org.typelevel.vault.Key

import java.nio.charset.StandardCharsets.UTF_8
import scala.concurrent.duration.Duration
import scala.util.Try

/*
 * Cookie based sessions for http4s
 *
 * @author Hugh Giddens
 * @author Iain Cardnell
 */

object SessionSyntax {
  implicit final class RequestOps[F[_]](val v: Request[F]) extends AnyVal {
    def session: Option[Session] = v.attributes.lookup(Session.requestAttr)
  }

  implicit final class ResponseOps[F[_]](val v: Response[F]) extends AnyVal {
    def clearSession: Response[F] =
      v.withAttribute(Session.responseAttr, (_: Option[Session]) => None)

    def modifySession(f: Session => Session):  Response[F] = {
      val lf: Option[Session] => Option[Session] = _.cata(f.andThen(_.some), None)
      v.withAttribute(Session.responseAttr,
        v.attributes.lookup(Session.responseAttr).cata(_.andThen(lf), lf))
    }

    def newOrModifySession(f: Option[Session] => Session): Response[F] = {
      val lf: Option[Session] => Option[Session] = f.andThen(_.some)
        v.withAttribute(Session.responseAttr,
          v.attributes.lookup(Session.responseAttr).cata(_.andThen(lf), lf))
    }

    def newSession(session: Session): Response[F] =
      v.withAttribute(Session.responseAttr, (_: Option[Session]) => Some(session))
  }
}

/**
  * Session Cookie Configuration
  *
  * @param secret 16 bytes secret for cookie security
  */
final case class SessionConfig(
  cookieName: String,
  mkCookie: (String, String) => ResponseCookie,
  secret: List[Byte],
  maxAge: Duration
) {
  private val KeySize = 16
  require(secret.length >= KeySize)
  // Fixme: Two keys should be derived for authentication and ciphering
  private val keyBytes = secret.take(KeySize).toArray

  private val logger = LoggerFactory.getLogger(this.getClass)

  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[this] def keySpec: SecretKeySpec =
    new SecretKeySpec(keyBytes, "AES")

  private[this] def encrypt(content: String): String = {
    val cipher = Cipher.getInstance("AES")
    cipher.init(Cipher.ENCRYPT_MODE, keySpec)
    Hex.encodeHexString(cipher.doFinal(content.getBytes(UTF_8)))
  }

  private[this] def decrypt(content: String): Option[String] = {
    val cipher = Cipher.getInstance("AES")
    cipher.init(Cipher.DECRYPT_MODE, keySpec)
    Try(new String(cipher.doFinal(Hex.decodeHex(content)), UTF_8)).toOption
  }

  private[this] def sign(content: String): String = {
    val signMac = Mac.getInstance("HmacSHA1")
    signMac.init(new SecretKeySpec(keyBytes, "HmacSHA1"))
    Base64
      .getEncoder
      .encodeToString(signMac.doFinal(content.getBytes(UTF_8)))
  }

  def cookie[F[_]: Sync](content: String): F[ResponseCookie] =
    Sync[F].delay {
      val now = new Date().getTime / 1000
      val expires = now + maxAge.toSeconds
      val serialized = s"$expires-$content"
      val encrypted = encrypt(serialized)
      val signed = sign(encrypted)
      val cookieValue = s"$signed-$encrypted"
      if (cookieValue.length > Session.cookieWarnLimit)
        logger.warn("Cookie size is too big and might be discarded by client browser. Actual size: {}", cookieValue.length)
      mkCookie(cookieName, cookieValue)
    }

  def check[F[_]: Sync](cookie: RequestCookie): F[Option[String]] =
    Sync[F].delay {
      val now = new Date().getTime / 1000
      cookie.content.split('-') match {
        case Array(signature, encrypted) if constantTimeEquals(signature, sign(encrypted)) =>
          for {
            decrypted <- decrypt(encrypted)
            Array(expires, content) = decrypted.split("-", 2)
            expiresSeconds <- Try(expires.toLong).toOption if expiresSeconds > now
          } yield content
        case _ =>
          None
      }
    }
}

object Session {
  private val logger = LoggerFactory.getLogger(this.getClass)

  val cookieWarnLimit = 4000

  val requestAttr: Key[Session] = Key.newKey[SyncIO, Session].unsafeRunSync()
  val responseAttr: Key[Option[Session] => Option[Session]] =
    Key.newKey[SyncIO, Option[Session] => Option[Session]].unsafeRunSync()

  private[this] def sessionAsCookie[F[_]: Sync](config: SessionConfig, session: Session): F[ResponseCookie] =
    config.cookie[F](session.noSpaces)

  private[this] def checkSignature[F[_]: Sync](
    config: SessionConfig,
    cookie: RequestCookie
  ): F[Option[Session]] =
    OptionT(config.check(cookie)).mapFilter(jawn.parse(_).toOption).value

  private[this] def sessionFromRequest[F[_]: Sync](
      config: SessionConfig,
      request: Request[F]
    ): F[Option[Session]] =
    (for {
      sessionCookie <- OptionT.fromOption[F](request.cookies.find(_.name === config.cookieName))
      session <- OptionT(checkSignature(config, sessionCookie))
    } yield session).value

  private[this] def debug[F[_]: Sync](msg: String): F[Unit] =
    Sync[F].delay(logger.debug(msg))

  def applySessionUpdates[F[_]: Sync](
    config: SessionConfig,
    sessionFromRequest: Option[Session],
    response: Response[F]): F[Response[F]] = {
      val updateSession = response.attributes.lookup(responseAttr)
        .getOrElse[Option[Session] => Option[Session]](identity)
      updateSession(sessionFromRequest).traverse(sessionAsCookie(config, _))
        .map(_.cata(
          response.addCookie,
          if (sessionFromRequest.isDefined) response.removeCookie(config.cookieName)
          else response
          )
        )
  }

  def sessionManagement[F[_]: Sync](config: SessionConfig): HttpMiddleware[F] =
    service => Kleisli { (req: Request[F]) =>
      for {
        _ <- OptionT.liftF(debug(s"starting for ${req.method} ${req.uri}"))
        sessionFromRequest <- OptionT.liftF(sessionFromRequest(config, req))
        requestWithSession = sessionFromRequest.cata(
          req.withAttribute(requestAttr, _),
          req
        )
        _ <- OptionT.liftF(printRequestSessionKeys(sessionFromRequest))
        response <- service(requestWithSession)
        responseWithSession <- OptionT.liftF(
          applySessionUpdates(config, sessionFromRequest, response)
        )
        _ <- OptionT.liftF(debug(s"finishing for ${req.method} ${req.uri}"))
      } yield responseWithSession
    }

  def sessionRequired[F[_] : Monad](fallback: F[Response[F]]): HttpMiddleware[F] =
    service => Kleisli { (req: Request[F]) =>
      import SessionSyntax._
      OptionT(req.session.pure[F])
        .flatMap(_ => service(req))
        .orElse(OptionT.liftF(fallback))
    }

  private[this] def printRequestSessionKeys[F[_]: Sync](sessionOpt: Option[Session]) =
     sessionOpt match {
        case Some(session) => debug("Request Session contains keys: " + session.asObject.map(_.toMap.keys.mkString(", ")))
        case None => debug("Request Session empty")
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy