JWT Scala
ライブラリ
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]"
}
}
}