【Scala】JWT ScalaのNativeだけ使用して簡単に認証を実装してみる

この記事は[ジャンルなしオンラインもくもく会 Advent Calendar 2021](https://adventar.org/calendars/6803)の23日目の記事です。ジャンルなしということなので、今回はScalaについて書いてみます。

## 概要
PlayFrameworkには様々認証の仕組みが用意されています。そこまで仰仰しい認証の仕組みは使わず、jwtでtokenの認証だけ出来れば良いという実装を行う場合、どうすればよいかというのを考えてみました。
今回は[JWT Scala](https://jwt-scala.github.io/jwt-scala/)のNativeの機能を使って、簡単にtoken認証する方法を紹介したいと思います。

## 対応方針
* 認証のライブラリは、JWT Scalaの[Native](https://jwt-scala.github.io/jwt-scala/jwt-core-jwt.html)を使用します。Nativeはフレームワークなどに依存せず、Jwt単体で使用できる機能です。
* Webのフレームワークには、PlayFrameworkを使用します。PlayFramework自体の認証機能は使いません。JWT Scalaの認証処理の組み込みは、[Implementing Authentication on Play Framework](https://pedrorijo.com/blog/scala-play-auth/)の「Starting raw」の項を参考に行います。

## 実装サンプル

### <ログイン処理(jwtトークンを生成)>
```scala:UseController.scala
@Singleton
class UserController @Inject()(val controllerComponents: ControllerComponents) extends BaseController with I18nSupport {

  import model.api.user.LoginByAccountCodeRequest.loginByAccountCodeRequestForm

  def loginByAccountCode() = Action { implicit request: Request[AnyContent] =>
    loginByAccountCodeRequestForm.bindFromRequest().fold(
      errors => BadRequest(errors.errorsAsJson),
      form => {
        // Googleの認証コードを使って認証する処理を想定（詳細は割愛）
        authByGoogleAuthCode(form.accountCode).fold(InternalServerError("Failed authentication"))(u =>
          // 認証OKだったらjwtトークンを返す
          Ok(Json.toJson(LoginResponse(
            u.name,
            u.getToken() // 後述で記載の「AuthUser」でjwtのトークン取得処理を実装
          )))
        )
      }
    )
  }
}
```

```scala:AuthUser.scala
object AuthUser {
  implicit val jsonWrites = Json.writes[AuthUser]
  implicit val jsonReads = Json.reads[AuthUser]
}

case class AuthUser(id: String = "", name: String = "") {
  val config = ConfigFactory.load()
  implicit val clock: Clock = Clock.systemUTC

  // jwtトークンの生成
  def getToken(): String = {
    // claimはJson形式で保存
    val claim = Json.toJson(this)
    Jwt.encode(
      JwtClaim(s"$claim").expiresIn(7776000), // 期限は3ヶ月
      config.getString("jwt.secret"), // confファイルから設定したsecretを取得
      JwtAlgorithm.HS256)
  }
}
```

### <認証処理(jwtトークンの検証)>

```scala:UseController.scala
@Singleton
class UserController @Inject()(val controllerComponents: ControllerComponents) extends BaseController with I18nSupport {
  import useCase.AuthUserUseCase._

  def loginCheckByToken() = Action { implicit request: Request[AnyContent] =>
    // 後述のAuthUserUseCase.scalaで渡されたトークンを検証
    withUser { authUser => {
      Ok(Json.toJson(LoginResponse(
        authUser.name,
        authUser.getToken()
      )))
    }
    }
  }
}
```

```scala:UseController.scala
object AuthUserUseCase {

  val config = ConfigFactory.load()

  def withUser[T](block: AuthUser => Result)(implicit request: Request[AnyContent]): Result = {
    // リクエストのAuthorizationヘッダーへ、トークンを設定している前提
    val authTokenOpt = request.headers.get("Authorization")
    authTokenOpt
      .flatMap(token => {
        AuthUser.getAuthUserFromToken(token) // 後述で記載の「AuthUser」でjwtトークンからユーザ情報取得を実装
      })
      .map(block)
      .getOrElse(Unauthorized)
  }
}
```

```scala:AuthUser.scala
object AuthUser {
  val config = ConfigFactory.load()

  implicit val jsonWrites = Json.writes[AuthUser]
  implicit val jsonReads = Json.reads[AuthUser]

  // tokenからユーザ情報を取得
  def getAuthUserFromToken(token: String): Option[AuthUser] = {
    // confファイルから設定したsecretを取得
    val secret = config.getString("jwt.secret")
    if (!Jwt.isValid(token, secret, Seq(JwtAlgorithm.HS256))) {
      return None
    }
    val decodedJwt = Jwt.decode(token, secret, Seq(JwtAlgorithm.HS256))
    decodedJwt.toOption.flatMap(claim => {
      // claimに保存されているJsonをパース
      Json.parse(claim.content).validate[AuthUser].asOpt
    })
  }
}

case class AuthUser(id: String = "",
                    name: String = "",
                    authMethod: String = "",
                   ) {
  val config = ConfigFactory.load()
  implicit val clock: Clock = Clock.systemUTC

  def getToken(): String = {
    val claim = Json.toJson(this)
    // 期限は3ヶ月
    Jwt.encode(
      JwtClaim(s"$claim").expiresIn(7776000),
      config.getString("jwt.secret"),
      JwtAlgorithm.HS256)
  }
}
```