A service is a Kleisli[OptionT[F, ?], Request[F], Response[F]]
, the composable version of
Request[F] => OptionT[F, Response[F]]
. A service with authentication also requires some kind of User
object which identifies which user did the request. To reference the User
object
along with the Request[F]
, there’s AuthedRequest[F, User]
, which is equivalent to
(User, Request[F])
. So the service has the signature AuthedRequest[F, User] =>
OptionT[F, Response[F]]
, or AuthedService[User, F]
. So we’ll need a Request[F] => Option[User]
function, or more likely, a Request[F] => OptionT[F, User]
, because the User
can
come from a database. We can convert that into an AuthMiddleware
and apply it.
Or in code, using cats.effect.IO
as the effect type:
import cats._, cats.effect._, cats.implicits._, cats.data._
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits._
import org.http4s.server._
case class User(id: Long, name: String)
val authUser1: Kleisli[OptionT[IO, ?], Request[IO], User] = Kleisli(_ => OptionT.liftF(IO(???)))
// authUser1: Kleisli[OptionT[IO, β$0$], Request[IO], User] = Kleisli(
// <function1>
// )
val authMiddleware1: AuthMiddleware[IO, User] = AuthMiddleware(authUser1)
// authMiddleware1: Kleisli[OptionT[IO, β$1$], AuthedRequest[IO, User], Response[IO]] => Kleisli[OptionT[IO, β$1$], Request[IO], Response[IO]] = org.http4s.server.package$AuthMiddleware$$$Lambda$13572/275538414@558201e6
val authedService: AuthedService[User, IO] =
AuthedService {
case GET -> Root / "welcome" as user => Ok(s"Welcome, ${user.name}")
}
// authedService: AuthedService[User, IO] = Kleisli(
// org.http4s.AuthedService$$$Lambda$13573/1121920094@71c65190
// )
val service1: HttpService[IO] = authMiddleware1(authedService)
// service1: HttpService[IO] = Kleisli(
// org.http4s.server.package$AuthMiddleware$$$Lambda$13574/728804549@55b0001e
// )
Usually, it should also be possible to send back a 401 in case there was no valid login. The 401 response can be adjusted as needed, some applications use a redirect to a login page, or a popup requesting login data. With the upcoming of SPA, the correct http status codes are relevant again.
To allow for failure, the authUser
function has to be adjusted to a Request[F]
=> F[Either[String,User]]
. So we’ll need to handle that possibility. For advanced
error handling, we recommend an error ADT instead of a String
.
val authUser2: Kleisli[IO, Request[IO], Either[String,User]] = Kleisli(_ => IO(???))
// authUser2: Kleisli[IO, Request[IO], Either[String, User]] = Kleisli(
// <function1>
// )
val onFailure2: AuthedService[String, IO] = Kleisli(req => OptionT.liftF(Forbidden(req.authInfo)))
// onFailure2: AuthedService[String, IO] = Kleisli(<function1>)
val authMiddleware2 = AuthMiddleware(authUser2, onFailure2)
// authMiddleware2: Kleisli[OptionT[IO, β$1$], AuthedRequest[IO, User], Response[IO]] => Kleisli[OptionT[IO, β$1$], Request[IO], Response[IO]] = org.http4s.server.package$AuthMiddleware$$$Lambda$13575/1857086211@71d91ccd
val service2: HttpService[IO] = authMiddleware2(authedService)
// service2: HttpService[IO] = Kleisli(
// cats.data.Kleisli$$$Lambda$13582/379054697@3491be21
// )
There’s a few different ways to send authorization information with a HTTP
request, the two most common are cookie for regular browser usage or the
Authorization
header for SPA.
We’ll use a small library for the signing/validation of the cookies, which basically contains the code used by the Play framework for this specific task.
libraryDependencies += "org.reactormonk" %% "cryptobits" % "1.2"
First, we’ll need to set the cookie. For the crypto instance, we’ll need to provide a private key. You usually want to set a static secret so people don’t lose their session on server restarts, and a static secret also allows you to use multiple application instances.
The message is simply the user id.
import org.reactormonk.{CryptoBits, PrivateKey}
import java.time._
val key = PrivateKey(scala.io.Codec.toUTF8(scala.util.Random.alphanumeric.take(20).mkString("")))
// key: PrivateKey = PrivateKey(
// Array(
// 117,
// 113,
// 120,
// 80,
// 74,
// 106,
// 118,
// 65,
// 97,
// 57,
// 54,
// 108,
// 89,
// 79,
// 116,
// 71,
// 98,
// 70,
// 74,
// 97
// )
// )
val crypto = CryptoBits(key)
// crypto: CryptoBits = CryptoBits(
// PrivateKey(
// Array(
// 117,
// 113,
// 120,
// 80,
// 74,
// 106,
// 118,
// 65,
// 97,
// 57,
// 54,
// 108,
// 89,
// 79,
// 116,
// 71,
// 98,
// 70,
// 74,
// 97
// )
// )
// )
val clock = Clock.systemUTC
// clock: Clock = SystemClock[Z]
def verifyLogin(request: Request[IO]): IO[Either[String,User]] = ??? // gotta figure out how to do the form // gotta figure out how to do the form
val logIn: Kleisli[IO, Request[IO], Response[IO]] = Kleisli({ request =>
verifyLogin(request: Request[IO]).flatMap(_ match {
case Left(error) =>
Forbidden(error)
case Right(user) => {
val message = crypto.signToken(user.id.toString, clock.millis.toString)
Ok("Logged in!").map(_.addCookie(Cookie("authcookie", message)))
}
})
})
// logIn: Kleisli[IO, Request[IO], Response[IO]] = Kleisli(<function1>)
Now that the cookie is set, we can retrieve it again in the authUser
.
def retrieveUser: Kleisli[IO, Long, User] = Kleisli(id => IO(???))
val authUser3: Kleisli[IO, Request[IO], Either[String,User]] = Kleisli({ request =>
val message = for {
header <- headers.Cookie.from(request.headers).toRight("Cookie parsing error")
cookie <- header.values.toList.find(_.name == "authcookie").toRight("Couldn't find the authcookie")
token <- crypto.validateSignedToken(cookie.content).toRight("Cookie invalid")
message <- Either.catchOnly[NumberFormatException](token.toLong).leftMap(_.toString)
} yield message
message.traverse(retrieveUser.run)
})
// authUser3: Kleisli[IO, Request[IO], Either[String, User]] = Kleisli(
// <function1>
// )
There is no inherent way to set the Authorization header, send the token in any
way that your SPA understands. Retrieve the header value in the authUser
function.
import org.http4s.util.string._
import org.http4s.headers.Authorization
val authUser4: Kleisli[IO, Request[IO], Either[String,User]] = Kleisli({ request =>
val message = for {
header <- request.headers.get(Authorization).toRight("Couldn't find an Authorization header")
token <- crypto.validateSignedToken(header.value).toRight("Cookie invalid")
message <- Either.catchOnly[NumberFormatException](token.toLong).leftMap(_.toString)
} yield message
message.traverse(retrieveUser.run)
})
// authUser4: Kleisli[IO, Request[IO], Either[String, User]] = Kleisli(
// <function1>
// )
The TSec project provides an authentication and authorization module for the http4s project 0.18-M4+. Docs specific to http4s are located Here.