🥰

http4s で簡単な JWT 認証

2023/02/04に公開

この記事は以下の記事の続きです.
https://zenn.dev/110416/articles/aaf5dc2ed7ccf4

https://zenn.dev/110416/articles/04d604b674badd

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]
  }

JwtAuthenticationRequest[IO] => Account または Request[IO] => Response[IO] に、userinfo が Account => Response[IO] になっているので、これらを組み合わせれば Request[IO] => Response[IO] が出来上がります.

図にすると下のようになります. AuthMiddleware は第一引数の KleisliLeft の場合は短絡して 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 と合成します. echoorNotFound がなくなったことに注意.

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

runecho を置き換えます.

    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