JWT Scala

認証JWTScalaセキュリティトークンPlay Framework

ライブラリ

JWT Scala

概要

JWT ScalaはScalaアプリケーション向けのJSON Web Token(JWT)ライブラリで、Play Framework、JSON4s、Circe等との統合をサポートします。

詳細

jwt-scalaは、Scala 2.12、2.13、Scala 3をサポートする包括的なJWTライブラリです。依存関係フリーのコアライブラリを提供し、Play Framework、Play JSON、Json4s Native、Json4s Jackson、Circe、uPickle、Argonautなどの人気JSONライブラリ向けの拡張モジュールを含んでいます。Java 8以降をサポートし、HMAC、RSA、ECDSA等の標準的な署名アルゴリズムを実装しています。プロジェクトは複数のサブプロジェクトに分かれており、必要なJSONライブラリに応じて適切なモジュールを選択できます。詳細なドキュメントはMicrositeで提供されており、エンタープライズアプリケーションでの使用に適した成熟したライブラリです。

メリット・デメリット

メリット

  • マルチライブラリ対応: 主要JSONライブラリとの統合サポート
  • 依存関係フリー: コアライブラリは外部依存なし
  • Scala全バージョン: Scala 2.12、2.13、Scala 3対応
  • Play Framework統合: Playアプリケーションとの自然な統合
  • モジュラー設計: 必要な機能のみを選択可能
  • 型安全性: Scalaの型システムによる安全性向上

デメリット

  • Scala固有: Scala以外の言語では使用不可
  • 学習コスト: Scalaエコシステムの理解が必要
  • 設定複雑性: JSONライブラリの選択と設定
  • ドキュメント: 一部のJSONライブラリ統合情報が限定的
  • パフォーマンス: JVM上での実行によるオーバーヘッド

主要リンク

書き方の例

基本的なJWT操作(コアライブラリ)

import pdi.jwt.{Jwt, JwtAlgorithm, JwtClaim, JwtHeader}
import java.time.Instant

// シークレットキー
val secret = "mySecretKey"

// クレームを作成
val claim = JwtClaim(
  content = """{"user_id": 123, "email": "[email protected]"}""",
  issuedAt = Some(Instant.now.getEpochSecond),
  expiration = Some(Instant.now.plusSeconds(3600).getEpochSecond)
)

// JWTトークンを生成
val token = Jwt.encode(claim, secret, JwtAlgorithm.HS256)
println(s"Generated token: $token")

// トークンをデコード・検証
Jwt.decode(token, secret, Seq(JwtAlgorithm.HS256)) match {
  case Success(claims) => 
    println(s"Successfully decoded: $claims")
  case Failure(exception) => 
    println(s"Failed to decode: ${exception.getMessage}")
}

Play JSON統合

import play.api.libs.json._
import pdi.jwt.{JwtJson, JwtAlgorithm, JwtClaim}

// ユーザーデータのケースクラス
case class User(id: Long, email: String, role: String)

object User {
  implicit val userFormat: Format[User] = Json.format[User]
}

// Play JSONでのJWT操作
object JwtService {
  private val secret = "mySecretKey"
  private val algorithm = JwtAlgorithm.HS256
  
  def createToken(user: User): String = {
    val claim = JwtClaim(
      content = Json.toJson(user).toString,
      issuedAt = Some(Instant.now.getEpochSecond),
      expiration = Some(Instant.now.plusSeconds(3600).getEpochSecond)
    )
    
    JwtJson.encode(claim, secret, algorithm)
  }
  
  def validateToken(token: String): Option[User] = {
    JwtJson.decode(token, secret, Seq(algorithm)) match {
      case Success(claims) =>
        Json.parse(claims.content).validate[User] match {
          case JsSuccess(user, _) => Some(user)
          case JsError(_) => None
        }
      case Failure(_) => None
    }
  }
}

// 使用例
val user = User(123, "[email protected]", "admin")
val token = JwtService.createToken(user)
val decodedUser = JwtService.validateToken(token)

Circe統合

import io.circe._
import io.circe.generic.auto._
import io.circe.parser._
import io.circe.syntax._
import pdi.jwt.{JwtCirce, JwtAlgorithm, JwtClaim}

// ユーザーデータ
case class UserClaims(
  userId: Long,
  email: String,
  role: String,
  permissions: List[String]
)

object CirceJwtService {
  private val secret = "mySecretKey"
  private val algorithm = JwtAlgorithm.HS256
  
  def encode(userClaims: UserClaims): String = {
    val claim = JwtClaim(
      content = userClaims.asJson.noSpaces,
      issuedAt = Some(Instant.now.getEpochSecond),
      expiration = Some(Instant.now.plusSeconds(3600).getEpochSecond)
    )
    
    JwtCirce.encode(claim, secret, algorithm)
  }
  
  def decode(token: String): Either[Error, UserClaims] = {
    JwtCirce.decode(token, secret, Seq(algorithm)) match {
      case Success(claims) =>
        parse(claims.content).flatMap(_.as[UserClaims])
      case Failure(exception) =>
        Left(new Error(s"JWT decode failed: ${exception.getMessage}"))
    }
  }
}

// 使用例
val userClaims = UserClaims(
  userId = 456,
  email = "[email protected]",
  role = "admin",
  permissions = List("read", "write", "delete")
)

val token = CirceJwtService.encode(userClaims)
CirceJwtService.decode(token) match {
  case Right(claims) => println(s"Decoded successfully: $claims")
  case Left(error) => println(s"Error: ${error.getMessage}")
}

Play Frameworkアプリケーションでの認証

// app/controllers/AuthController.scala
import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class AuthController @Inject()(
  val controllerComponents: ControllerComponents
)(implicit ec: ExecutionContext) extends BaseController {
  
  def login = Action.async(parse.json) { request =>
    request.body.validate[LoginRequest] match {
      case JsSuccess(loginReq, _) =>
        // ユーザー認証ロジック(簡略化)
        authenticateUser(loginReq.email, loginReq.password).map {
          case Some(user) =>
            val token = JwtService.createToken(user)
            Ok(Json.obj(
              "token" -> token,
              "user" -> Json.toJson(user)
            ))
          case None =>
            Unauthorized(Json.obj("error" -> "Invalid credentials"))
        }
      case JsError(errors) =>
        Future.successful(BadRequest(Json.obj("error" -> "Invalid request")))
    }
  }
  
  def me = AuthenticatedAction { request =>
    Ok(Json.toJson(request.user))
  }
  
  private def authenticateUser(email: String, password: String): Future[Option[User]] = {
    // データベースからユーザーを取得し、パスワードを検証
    // 実際の実装では適切な暗号化とデータベースアクセスを使用
    Future.successful(Some(User(1, email, "user")))
  }
}

// カスタム認証アクション
case class AuthenticatedRequest[A](user: User, request: Request[A]) extends WrappedRequest[A](request)

class AuthenticatedAction @Inject()(
  parser: BodyParsers.Default
)(implicit ec: ExecutionContext) extends ActionBuilderImpl(parser) {
  
  override def invokeBlock[A](request: Request[A], block: AuthenticatedRequest[A] => Future[Result]): Future[Result] = {
    request.headers.get("Authorization") match {
      case Some(authHeader) if authHeader.startsWith("Bearer ") =>
        val token = authHeader.substring(7)
        JwtService.validateToken(token) match {
          case Some(user) =>
            block(AuthenticatedRequest(user, request))
          case None =>
            Future.successful(Results.Unauthorized(Json.obj("error" -> "Invalid token")))
        }
      case _ =>
        Future.successful(Results.Unauthorized(Json.obj("error" -> "Missing authorization header")))
    }
  }
}

権限管理システム

// models/Permission.scala
sealed trait Permission
object Permission {
  case object ReadPosts extends Permission
  case object WritePosts extends Permission
  case object DeletePosts extends Permission
  case object ManageUsers extends Permission
  
  implicit val permissionFormat: Format[Permission] = new Format[Permission] {
    def reads(json: JsValue): JsResult[Permission] = json.as[String] match {
      case "read_posts" => JsSuccess(ReadPosts)
      case "write_posts" => JsSuccess(WritePosts)
      case "delete_posts" => JsSuccess(DeletePosts)
      case "manage_users" => JsSuccess(ManageUsers)
      case other => JsError(s"Unknown permission: $other")
    }
    
    def writes(permission: Permission): JsValue = JsString(permission match {
      case ReadPosts => "read_posts"
      case WritePosts => "write_posts"
      case DeletePosts => "delete_posts"
      case ManageUsers => "manage_users"
    })
  }
}

// 権限付きユーザー
case class AuthorizedUser(
  id: Long,
  email: String,
  role: String,
  permissions: Set[Permission]
) {
  def hasPermission(permission: Permission): Boolean = {
    role == "admin" || permissions.contains(permission)
  }
}

// 権限チェック用アクション
class AuthorizedAction(requiredPermission: Permission) @Inject()(
  parser: BodyParsers.Default
)(implicit ec: ExecutionContext) extends ActionBuilderImpl(parser) {
  
  override def invokeBlock[A](request: Request[A], block: Request[A] => Future[Result]): Future[Result] = {
    // JWT トークンから権限を取得し、チェック
    extractUserFromToken(request) match {
      case Some(user) if user.hasPermission(requiredPermission) =>
        block(request)
      case Some(_) =>
        Future.successful(Results.Forbidden(Json.obj("error" -> "Insufficient permissions")))
      case None =>
        Future.successful(Results.Unauthorized(Json.obj("error" -> "Authentication required")))
    }
  }
  
  private def extractUserFromToken(request: Request[_]): Option[AuthorizedUser] = {
    // JWT トークンから AuthorizedUser を抽出
    // 実装詳細は省略
    None
  }
}

JWT設定とミドルウェア

// app/filters/JwtFilter.scala
import akka.stream.Materializer
import javax.inject._
import play.api.mvc._
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class JwtFilter @Inject()(
  implicit override val mat: Materializer,
  exec: ExecutionContext
) extends Filter {
  
  override def apply(nextFilter: RequestHeader => Future[Result])
                   (requestHeader: RequestHeader): Future[Result] = {
    
    // 認証が不要なパスをスキップ
    if (!requiresAuthentication(requestHeader.path)) {
      return nextFilter(requestHeader)
    }
    
    requestHeader.headers.get("Authorization") match {
      case Some(authHeader) if authHeader.startsWith("Bearer ") =>
        val token = authHeader.substring(7)
        JwtService.validateToken(token) match {
          case Some(user) =>
            // ユーザー情報をリクエストに追加
            val enrichedHeader = requestHeader.withAttrs(
              requestHeader.attrs + (Attrs.User -> user)
            )
            nextFilter(enrichedHeader)
          case None =>
            Future.successful(Results.Unauthorized(Json.obj("error" -> "Invalid token")))
        }
      case _ =>
        Future.successful(Results.Unauthorized(Json.obj("error" -> "Missing authorization")))
    }
  }
  
  private def requiresAuthentication(path: String): Boolean = {
    val publicPaths = Set("/login", "/register", "/health", "/")
    !publicPaths.contains(path) && !path.startsWith("/assets")
  }
}

// リクエスト属性の定義
object Attrs {
  val User = TypedKey[User]("user")
}

// application.conf
play.filters.enabled += "filters.JwtFilter"

テストでのJWT使用

// test/controllers/AuthControllerSpec.scala
import org.scalatestplus.play._
import org.scalatestplus.play.guice._
import play.api.test._
import play.api.test.Helpers._
import play.api.libs.json._

class AuthControllerSpec extends PlaySpec with GuiceOneAppPerTest with Injecting {
  
  "AuthController" should {
    
    "authenticate valid user" in {
      val request = FakeRequest(POST, "/auth/login")
        .withJsonBody(Json.obj(
          "email" -> "[email protected]",
          "password" -> "password123"
        ))
      
      val result = route(app, request).get
      
      status(result) mustBe OK
      (contentAsJson(result) \ "token").asOpt[String] mustBe defined
    }
    
    "reject invalid credentials" in {
      val request = FakeRequest(POST, "/auth/login")
        .withJsonBody(Json.obj(
          "email" -> "[email protected]",
          "password" -> "wrongpassword"
        ))
      
      val result = route(app, request).get
      
      status(result) mustBe UNAUTHORIZED
    }
    
    "return user info for valid token" in {
      val user = User(1, "[email protected]", "user")
      val token = JwtService.createToken(user)
      
      val request = FakeRequest(GET, "/auth/me")
        .withHeaders("Authorization" -> s"Bearer $token")
      
      val result = route(app, request).get
      
      status(result) mustBe OK
      (contentAsJson(result) \ "email").as[String] mustBe "[email protected]"
    }
  }
}