pac4j Scala

認証ライブラリScalaPlay FrameworkOAuthSAMLOpenID Connect認証認可

認証ライブラリ

pac4j Scala

概要

pac4j Scalaは、Play Framework向けのセキュリティライブラリで、OAuth、CAS、SAML、OpenID Connectなどの認証をScalaで提供します。

詳細

pac4j Scalaは、Scala言語とPlay Frameworkのためのセキュリティライブラリです。Apache 2ライセンスの下で提供され、pac4jセキュリティエンジンをベースにしたPlay Framework v2のWebアプリケーションおよびWebサービス向けの包括的なセキュリティソリューションです。OAuth(Facebook、Twitter、Google等)、SAML、CAS、OpenID Connect、HTTP、Google App Engine、Kerberos、LDAP、SQL、JWT、MongoDB、CouchDB、IPアドレス、REST APIなど幅広い認証メカニズムをサポートしています。ロール、匿名/記憶/完全認証ユーザー、プロファイルタイプと属性、CORS、CSRF、セキュリティヘッダー、IPアドレス、HTTPメソッド制限などの認可機能も提供します。SecureアノテーションとSecurityトレイトでメソッドを保護し、SecurityFilterでURLを保護できます。Deadboltとの連携も可能で、Play 2.6〜2.8およびScala 2.11〜2.13との互換性があります。

メリット・デメリット

メリット

  • Play Framework特化: Play Framework v2との完全統合
  • Scala言語サポート: Scalaの型安全性と関数型プログラミング活用
  • 多様な認証: OAuth、SAML、OpenID Connect、JWT等幅広い認証メカニズム
  • 関数型アプローチ: Scalaの特性を活かした関数型認証処理
  • 型安全性: コンパイル時の型チェックによる安全性確保
  • 非同期対応: Play Frameworkの非同期処理に最適化
  • デモ充実: play-pac4j-scala-demoによる実装例提供

デメリット

  • Play限定: Play Framework以外では使用不可
  • Scala知識: Scala言語の習熟が必要
  • 学習コスト: pac4jとScalaの両方の理解が必要
  • コミュニティ規模: Java版と比較してコミュニティが小さい
  • バージョン依存: Play/Scalaバージョンとの厳密な依存関係
  • ドキュメント: 日本語ドキュメントが限定的

主要リンク

書き方の例

基本的なPlay設定(application.conf)

# application.conf
play.modules.enabled += "org.pac4j.play.PlayModule"
play.http.filters += "org.pac4j.play.filters.SecurityFilter"

# pac4j設定
pac4j {
  security {
    rules = [
      {
        "/admin/.*" = {
          authorizers = "admin"
          clients = "FormClient"
        }
      },
      {
        "/.*" = {
          authorizers = "isAuthenticated"
          clients = "FacebookClient,TwitterClient,GoogleOidcClient"
        }
      }
    ]
  }
}

SecurityModuleの設定

// modules/SecurityModule.scala
import com.google.inject.{AbstractModule, Provides}
import org.pac4j.core.config.Config
import org.pac4j.core.client.Clients
import org.pac4j.http.client.indirect.FormClient
import org.pac4j.http.credentials.authenticator.test.SimpleTestUsernamePasswordAuthenticator
import org.pac4j.oauth.client.{FacebookClient, TwitterClient}
import org.pac4j.oidc.client.GoogleOidcClient
import org.pac4j.oidc.config.OidcConfiguration
import org.pac4j.play.{CallbackController, LogoutController}
import org.pac4j.play.store.{PlayCookieSessionStore, PlaySessionStore}
import play.api.{Configuration, Environment}

class SecurityModule(environment: Environment, configuration: Configuration) 
  extends AbstractModule {

  override def configure(): Unit = {
    bind(classOf[PlaySessionStore]).to(classOf[PlayCookieSessionStore])
  }

  @Provides
  def provideConfig: Config = {
    // Facebook OAuth設定
    val facebookClient = new FacebookClient(
      configuration.get[String]("pac4j.facebook.id"),
      configuration.get[String]("pac4j.facebook.secret")
    )

    // Twitter OAuth設定
    val twitterClient = new TwitterClient(
      configuration.get[String]("pac4j.twitter.id"),
      configuration.get[String]("pac4j.twitter.secret")
    )

    // Google OpenID Connect設定
    val oidcConfig = new OidcConfiguration()
    oidcConfig.setClientId(configuration.get[String]("pac4j.google.id"))
    oidcConfig.setSecret(configuration.get[String]("pac4j.google.secret"))
    oidcConfig.setIssuer("https://accounts.google.com")
    val googleOidcClient = new GoogleOidcClient(oidcConfig)

    // フォーム認証設定
    val formClient = new FormClient("/loginForm", 
      new SimpleTestUsernamePasswordAuthenticator())

    val clients = new Clients("/callback", 
      facebookClient, twitterClient, googleOidcClient, formClient)

    new Config(clients)
  }

  @Provides 
  def provideCallbackController(config: Config): CallbackController = {
    new CallbackController()
  }

  @Provides
  def provideLogoutController(config: Config): LogoutController = {
    new LogoutController()
  }
}

コントローラーでの認証制御

// controllers/ApplicationController.scala
import javax.inject.Inject
import org.pac4j.core.profile.{CommonProfile, ProfileManager}
import org.pac4j.play.scala.{Security, SecurityComponents}
import play.api.mvc._

class ApplicationController @Inject()(
  val controllerComponents: SecurityComponents
) extends Security[CommonProfile] {

  // 認証が必要なアクション
  def secure: Action[AnyContent] = Secure("FacebookClient") { implicit request =>
    val profile = request.profiles.headOption
    val name = profile.map(_.getDisplayName).getOrElse("Unknown")
    Ok(views.html.secure(name))
  }

  // 複数クライアント対応
  def multiAuth: Action[AnyContent] = 
    Secure("FacebookClient,TwitterClient,GoogleOidcClient") { implicit request =>
    val profiles = request.profiles
    Ok(views.html.multiAuth(profiles))
  }

  // 管理者権限が必要
  def admin: Action[AnyContent] = 
    Secure("FormClient", "admin") { implicit request =>
    Ok(views.html.admin())
  }

  // カスタム認可
  def customAuth: Action[AnyContent] = 
    Secure("FacebookClient", "custom") { implicit request =>
    Ok("Custom authorized content")
  }

  // プロファイル情報表示
  def profile: Action[AnyContent] = Secure("FacebookClient") { implicit request =>
    val profile = request.profiles.head
    val attributes = profile.getAttributes.asScala.toMap
    Ok(views.html.profile(profile, attributes))
  }
}

カスタムオーソライザー

// security/CustomAuthorizer.scala
import org.pac4j.core.authorization.authorizer.ProfileAuthorizer
import org.pac4j.core.context.WebContext
import org.pac4j.core.profile.CommonProfile
import scala.jdk.CollectionConverters._

class CustomAuthorizer extends ProfileAuthorizer[CommonProfile] {
  
  override def isAuthorized(context: WebContext, 
                           profiles: java.util.List[CommonProfile]): Boolean = {
    val scalaProfiles = profiles.asScala.toList
    
    scalaProfiles.headOption match {
      case Some(profile) =>
        // カスタム認可ロジック
        profile.getAttribute("department") match {
          case dept: String if dept == "Engineering" => true
          case _ => false
        }
      case None => false
    }
  }
}

// SecurityModule.scalaでの登録
@Provides
def provideConfig: Config = {
  // ... clients設定 ...
  
  val config = new Config(clients)
  config.addAuthorizer("custom", new CustomAuthorizer())
  config
}

非同期アクションでの認証

// controllers/AsyncController.scala
import scala.concurrent.{ExecutionContext, Future}
import org.pac4j.core.profile.CommonProfile
import org.pac4j.play.scala.Security

class AsyncController @Inject()(
  val controllerComponents: SecurityComponents
)(implicit ec: ExecutionContext) extends Security[CommonProfile] {

  def asyncSecure: Action[AnyContent] = 
    Secure("FacebookClient").async { implicit request =>
    Future {
      val profile = request.profiles.head
      val userId = profile.getId
      
      // 非同期処理
      processUserData(userId).map { result =>
        Ok(s"Processed: $result")
      }
    }.flatten
  }

  def processUserData(userId: String): Future[String] = {
    // データベースアクセスなどの非同期処理
    Future.successful(s"User data for $userId")
  }
}

ユーザープロファイル管理

// services/ProfileService.scala
import org.pac4j.core.profile.CommonProfile
import play.api.libs.json._
import scala.jdk.CollectionConverters._

class ProfileService {

  def extractUserInfo(profile: CommonProfile): UserInfo = {
    UserInfo(
      id = profile.getId,
      displayName = profile.getDisplayName,
      email = Option(profile.getAttribute("email")).map(_.toString),
      firstName = Option(profile.getFirstName),
      familyName = Option(profile.getFamilyName),
      roles = profile.getRoles.asScala.toSet,
      attributes = profile.getAttributes.asScala.toMap
    )
  }

  def toJson(userInfo: UserInfo): JsValue = {
    Json.obj(
      "id" -> userInfo.id,
      "displayName" -> userInfo.displayName,
      "email" -> userInfo.email,
      "firstName" -> userInfo.firstName,
      "familyName" -> userInfo.familyName,
      "roles" -> userInfo.roles,
      "attributes" -> userInfo.attributes
    )
  }
}

case class UserInfo(
  id: String,
  displayName: String,
  email: Option[String],
  firstName: Option[String],
  familyName: Option[String],
  roles: Set[String],
  attributes: Map[String, AnyRef]
)

WebSocketでの認証

// controllers/WebSocketController.scala
import akka.actor.ActorSystem
import akka.stream.Materializer
import org.pac4j.core.profile.CommonProfile
import play.api.libs.streams.ActorFlow
import play.api.mvc.WebSocket
import actors.WebSocketActor

class WebSocketController @Inject()(
  val controllerComponents: SecurityComponents
)(implicit system: ActorSystem, mat: Materializer) 
  extends Security[CommonProfile] {

  def socket: WebSocket = 
    WebSocket.acceptOrResult[String, String] { implicit request =>
    Future {
      val profileManager = new ProfileManager[CommonProfile](request.webContext)
      profileManager.get(true).asScala.headOption match {
        case Some(profile) =>
          Right(ActorFlow.actorRef { out =>
            WebSocketActor.props(out, profile)
          })
        case None =>
          Left(Forbidden("Authentication required"))
      }
    }
  }
}