http4s で簡単な JWT 認証
この記事は以下の記事の続きです.
http://localhost:8080/echo/name に GET リクエストを送ると name
が返ってくるサーバーは以下のように書けます. (http4s の最小構成: サーバーのコードと同一.)
// 非同期ランタイム
//> using lib "org.typelevel::cats-effect:3.4.5"
// http 関係
// http の基本型など
//> using lib "org.http4s::http4s-core:1.0.0-M38"
// ルーティング DSL など
//> using lib "org.http4s::http4s-dsl:1.0.0-M38"
// http サーバー
//> using lib "org.http4s::http4s-ember-server:1.0.0-M38"
import cats.effect._
import org.http4s.HttpRoutes
// Request => F[Response] を表現する型
import com.comcast.ip4s._
// ipv4"0.0.0.0", port"8080" などを書けるようにする(不正な値はコンパイルエラー.)
import org.http4s.dsl.io._
// case GET -> Root / "echo" / arg => などを書けるようにする
import org.http4s.implicits._
import org.http4s.ember.server._
object Main extends IOApp {
val echo = HttpRoutes.of[IO] {
case GET -> Root / "echo" / arg => Ok(arg)
}.orNotFound
def run(args: List[String]):IO[ExitCode] = EmberServerBuilder
.default[IO] // EmberServerBuilder[cats.effect.IO]
.withHost(ipv4"0.0.0.0")
.withPort(port"8080")
.withHttpApp(echo)
.build // Resource[IO,Server]
.useForever // IO[Nothing]
.as(ExitCode.Success) // IO[ExitCode]
}
jwt-scala の依存を追加します.
//> using lib "com.github.jwt-scala::jwt-core:9.1.2"
import pdi.*
import jwt.*
おもむろにアカウント情報をあらわす Account
型を追加します.
case class Account(id: String, name: String)
そしてリクエストから何らかの方法でデコードした Account
を利用する API を定義します. ユーザーのアカウント id とリクエストされた id が同じ時だけレスポンスを返します. 認証情報と異なるユーザーの id を指定した場合は Forbidden にします. よくある mypage 機能の簡易版ですね. リクエストのユーザーアカウントを jwt から取得するケースでは、jwt の署名を正しく検証していれば改竄されていないことがわかるので安全です.
val userinfo = AuthedRoutes.of[Account, IO] {
case GET -> Root / "get_account_info" / id as account if account.id == id =>
Ok(user.name)
case GET -> Root / "get_account_info" / id as _ => Forbidden()
}
不正な場合に Forbidden を返さない場合は以下のように Root / "get_account_info" / id as _
を省いても Ok です.
val userinfo = AuthedRoutes.of[Account, IO] {
case GET -> Root / "get_account_info" / id as account if account.id == id =>
Ok(user.name)
}
次は jwt 認証をするためのミドルウェアを書きます. クライアントからリクエストが飛んできたらまず jwt の検証をして問題がなければ後続の処理に、検証して不正だった時はエラーレスポンスを返す処理を書いていきます. このようにリクエスト・レスポンスをinterceptして処理を追加する際に http4s の Middleware を使います.
val auth: Kleisli[IO, Request[IO], Either[String, Account]] = ???
val onFailure: AuthedRoutes[String, IO] = ???
val JwtAuthentication = AuthMiddleware(auth, onFailure)
Kleisli
とかいう怖い文字が出てきましたね🤔 これは Request[IO]
型のリクエストを受け取って、IO[Either[String,Account]]
を返す関数をあらわしています. つまり Request[IO] => IO[Either[String,Account]]
です.
IO[Either[String,Account]]
は IO
の文脈で JWT の検証に成功したら Account
を, 失敗したら エラーメッセージ String
を返すことをあらわしています.
IO
の文脈というのはリクエストを受け取った後にファイルを読み書きしたり外部のサービスに検証のリクエストを送ったりするかもしれないことを含意しています.
まずは失敗時、つまりリクエストが認証で弾かれたケースを書きます.
失敗時にはエラーメッセージの型 String
が流れてくるので レスポンスの型にリフトします.
val onFailure: AuthedRoutes[String, IO] =
Kleisli(req => OptionT.liftF(Forbidden(req.context)))
次に認証処理を書きます. 今回はトークンが共通鍵で署名されていて正しくデコードできればOKとします.
val secretKey = "53cr3t"
val authenticate = (claim: JwtClaim) => Account(claim.content, "yourself")
val auth: Kleisli[IO, Request[IO], Either[String, Account]] =
Kleisli { request =>
val token = request.headers.get[Authorization] match
case Some(Authorization(Token(AuthScheme.Bearer, token))) => Some(token)
case _ => None
val claim = token match
case None => "Bearer token not found".asLeft[JwtClaim]
case Some(token) =>
pdi.jwt.Jwt
.decode(token, secretKey, Seq(pdi.jwt.JwtAlgorithm.HS256))
.toEither
.leftMap(_ => "Invalid token")
claim.map(authenticate).pure[IO]
}
JwtAuthentication
が Request[IO] => Account
または Request[IO] => Response[IO]
に、userinfo が Account => Response[IO]
になっているので、これらを組み合わせれば Request[IO] => Response[IO]
が出来上がります.
図にすると下のようになります. AuthMiddleware
は第一引数の Kleisli
が Left
の場合は短絡して onFailure に繋げてくれます. 下の囲った部分が AuthMiddleware の責務です.
response 200 <-------------------------- .
|
|---- auth middleware ----| |
request --> authentication ---> do something with Account
| | |
| ↓ |
response 403 <-- forbidden with String error message
|-------------------------|
これで AuthMiddleware
は完成です. ユーザーの認証情報が必要な API を JwtAuthentication
ミドルウェアで包みます.
val securedRoutes: HttpRoutes[IO] = JwtAuthentication(userinfo)
後は既存の echo
と合成します. echo
の orNotFound
がなくなったことに注意.
val echo = HttpRoutes.of[IO] {
case GET -> Root / "echo" / arg => Ok(arg)
}
// GET echo/<params>, GET /get_account_info/<params> 以外のルーティングは
// 定義されていないので partialRoutes と名づけている.
val partialRoutes = echo <+> securedRoutes
run
の echo
を置き換えます.
def run(args: List[String]):IO[ExitCode] = EmberServerBuilder
.default[IO] // EmberServerBuilder[cats.effect.IO]
.withHost(ipv4"0.0.0.0")
.withPort(port"8080")
.withHttpApp(partialRoutes.orNotFound) // 定義されていないルーティングは全て NotFound にフォールバックする
.build // Resource[IO,Server]
.useForever // IO[Nothing]
.as(ExitCode.Success) // IO[ExitCode]
curl -X GET -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.bXlpZA.oK1-EymgTxFWull3-NzgauIgkaPJ_WMl_4-QMc5WxMs" http://localhost:8888/get_account_info/myid
サーバーを起動して上のように curl を実行すると yourserlf
というテキストが返ってくるはずです. 一方で、myid
を違う値に置き換えると Forbidden が返ってくるはずです.
Discussion