Silhouette

認証ライブラリPlay FrameworkScalaOAuthJWT多要素認証セキュリティ

認証ライブラリ

Silhouette

概要

SilhouetteはPlay Frameworkアプリケーション向けの包括的な認証ライブラリです。OAuth1、OAuth2、OpenID、CAS、多要素認証、TOTP、資格情報認証、基本認証、カスタム認証スキームなど、複数の認証方式をサポートしています。

詳細

Silhouetteは、Play Framework用の堅牢で包括的な認証ライブラリとして、2024年現在も活発に開発が続けられています。元々はmohiva/play-silhouetteリポジトリで開発されていましたが、現在はplayframework/play-silhouetteフォークで積極的にメンテナンスされ、最新のスナップショットビルドが提供されています。認証の心臓部であるAuthenticatorシステムにより、JWTトークン、セッションベース、クッキーベースなど多様な認証方式を統一的に扱うことができます。非同期・ノンブロッキング処理、国際化対応、テストカバレッジも充実しており、エンタープライズレベルのアプリケーション開発に最適です。Silhouette 7.0がScala 2.12/2.13およびPlay 2.8の最終バージョンですが、現在のフォークでは継続的な更新とスナップショットリリースが提供されています。

メリット・デメリット

メリット

  • 包括的な認証方式: OAuth1/2、OpenID、CAS、JWT、TOTPなど幅広い対応
  • Play Framework統合: 完全なPlay Framework統合による一貫した開発体験
  • 多要素認証: 2FA、TOTPによる高度なセキュリティ対応
  • 非同期処理: 完全な非同期・ノンブロッキングアーキテクチャ
  • カスタマイザブル: 独自認証スキームの柔軟な実装が可能
  • 国際化対応: 多言語サポートとローカライゼーション
  • テストサポート: 充実したテストユーティリティとカバレッジ
  • ドキュメント: 豊富なドキュメントとサンプルコード

デメリット

  • 学習コスト: 豊富な機能による設定と学習の複雑さ
  • Play Framework依存: Play Framework専用でフレームワーク依存が強い
  • 設定複雑性: 初期設定とカスタマイゼーションの複雑さ
  • メンテナンス状況: 公式版と非公式フォークの混在
  • バージョン管理: スナップショット版使用時の安定性考慮が必要
  • リソース消費: 豊富な機能による若干のメモリ使用量
  • 依存関係: 多数の外部ライブラリへの依存

主要リンク

書き方の例

基本的なSilhouette設定

// build.sbt での依存関係設定
libraryDependencies ++= Seq(
  "org.playframework.silhouette" %% "play-silhouette" % "7.0.0",
  "org.playframework.silhouette" %% "play-silhouette-password-bcrypt" % "7.0.0",
  "org.playframework.silhouette" %% "play-silhouette-crypto-jca" % "7.0.0",
  "org.playframework.silhouette" %% "play-silhouette-persistence" % "7.0.0",
  "org.playframework.silhouette" %% "play-silhouette-testkit" % "7.0.0" % Test
)

// application.conf での設定
silhouette {
  # JWT認証の設定
  jwt.authenticator {
    headerName = "X-Auth-Token"
    issuerClaim = "play-silhouette"
    encryptSubject = true
    authenticatorExpiry = 12 hours
    sharedSecret = "changeme"
  }
  
  # OAuth設定
  oauth1TokenSecretProvider.cookieName="OAuth1TokenSecret"
  oauth1TokenSecretProvider.cookiePath="/"
  oauth1TokenSecretProvider.secureCookie=false // HTTPSの場合はtrue
  oauth1TokenSecretProvider.httpOnlyCookie=true
}

ユーザーモデルとIdentity定義

// ユーザーモデル
import play.silhouette.api.{Identity, LoginInfo}
import java.util.UUID

case class User(
  userID: UUID,
  loginInfo: LoginInfo,
  firstName: Option[String],
  lastName: Option[String],
  fullName: Option[String],
  email: Option[String],
  avatarURL: Option[String],
  activated: Boolean
) extends Identity

// UserService trait
import scala.concurrent.Future

trait UserService {
  def retrieve(loginInfo: LoginInfo): Future[Option[User]]
  def save(user: User): Future[User]
}

// UserServiceの実装例
class UserServiceImpl extends UserService {
  def retrieve(loginInfo: LoginInfo): Future[Option[User]] = {
    // データベースからユーザーを取得
    Future.successful(None) // 実装例
  }
  
  def save(user: User): Future[User] = {
    // ユーザーをデータベースに保存
    Future.successful(user)
  }
}

Silhouette Environment設定

// Silhouette Environment の設定
import play.silhouette.api._
import play.silhouette.api.util._
import play.silhouette.impl.authenticators._
import play.silhouette.impl.providers._
import play.silhouette.impl.providers.credentials._
import play.silhouette.impl.util._
import play.silhouette.password.BCryptPasswordHasher
import play.api.libs.ws.WSClient

// DI コンテナでの設定例
class SilhouetteModule extends AbstractModule {
  def configure(): Unit = {
    bind[Silhouette[DefaultEnv]].to[SilhouetteProvider[DefaultEnv]]
    bind[UserService].to[UserServiceImpl]
    bind[CacheLayer].to[PlayCacheLayer]
    bind[IDGenerator].toInstance(new SecureRandomIDGenerator())
    bind[PasswordHasher].toInstance(new BCryptPasswordHasher)
    bind[FingerprintGenerator].toInstance(new DefaultFingerprintGenerator(false))
    bind[EventBus].toInstance(EventBus())
    bind[Clock].toInstance(Clock())
  }
  
  @Provides
  def provideHTTPLayer(client: WSClient): HTTPLayer = new PlayHTTPLayer(client)
  
  @Provides
  def provideEnvironment(
    userService: UserService,
    authenticatorService: AuthenticatorService[JWTAuthenticator],
    eventBus: EventBus
  ): Environment[DefaultEnv] = {
    Environment[DefaultEnv](
      userService,
      authenticatorService,
      Seq(),
      eventBus
    )
  }
}

// Environment type alias
trait DefaultEnv extends Env {
  type I = User
  type A = JWTAuthenticator
}

JWT認証の実装

// JWT Authenticator の設定
import play.silhouette.impl.authenticators._
import play.silhouette.api.util.Clock
import scala.concurrent.duration._

@Provides
def provideJWTAuthenticatorService(
  settings: JWTAuthenticatorSettings,
  repository: AuthenticatorRepository[JWTAuthenticator],
  crypter: Crypter,
  idGenerator: IDGenerator,
  clock: Clock
): AuthenticatorService[JWTAuthenticator] = {
  new JWTAuthenticatorService(settings, repository, crypter, idGenerator, clock)
}

@Provides
def provideJWTAuthenticatorSettings(): JWTAuthenticatorSettings = {
  JWTAuthenticatorSettings(
    fieldName = "X-Auth-Token",
    issuerClaim = "play-silhouette",
    authenticatorExpiry = 12.hours,
    sharedSecret = "your-secret-key"
  )
}

// コントローラーでのJWT認証使用
class AuthController @Inject()(
  silhouette: Silhouette[DefaultEnv],
  userService: UserService,
  credentialsProvider: CredentialsProvider,
  passwordHasherRegistry: PasswordHasherRegistry
) extends AbstractController with SilhouetteController {

  def signIn = silhouette.UnsecuredAction.async { implicit request =>
    SignInForm.form.bindFromRequest.fold(
      form => Future.successful(BadRequest(Json.obj("message" -> "Invalid form"))),
      data => {
        credentialsProvider.authenticate(Credentials(data.email, data.password)).flatMap { loginInfo =>
          userService.retrieve(loginInfo).flatMap {
            case Some(user) if user.activated =>
              silhouette.env.authenticatorService.create(loginInfo).flatMap { authenticator =>
                silhouette.env.eventBus.publish(LoginEvent(user, request))
                silhouette.env.authenticatorService.init(authenticator).flatMap { token =>
                  silhouette.env.authenticatorService.embed(token, Ok(Json.obj(
                    "token" -> token,
                    "user" -> Json.obj("email" -> user.email)
                  )))
                }
              }
            case Some(_) =>
              Future.successful(Unauthorized(Json.obj("message" -> "Account not activated")))
            case None =>
              Future.successful(Unauthorized(Json.obj("message" -> "Invalid credentials")))
          }
        }.recover {
          case _: ProviderException =>
            Unauthorized(Json.obj("message" -> "Invalid credentials"))
        }
      }
    )
  }
}

OAuth2実装

// OAuth2プロバイダーの設定
import play.silhouette.impl.providers.oauth2._

@Provides
def provideGoogleProvider(
  httpLayer: HTTPLayer,
  stateHandler: OAuth2StateHandler
): GoogleProvider = {
  new GoogleProvider(httpLayer, stateHandler, OAuth2Settings(
    authorizationURL = Some("https://accounts.google.com/o/oauth2/auth"),
    accessTokenURL = "https://accounts.google.com/o/oauth2/token",
    redirectURL = Some("http://localhost:9000/authenticate/google"),
    clientID = "your-google-client-id",
    clientSecret = "your-google-client-secret",
    scope = Some("profile email")
  ))
}

// OAuth2認証コントローラー
def authenticateGoogle = silhouette.UnsecuredAction.async { implicit request =>
  googleProvider.authenticate().flatMap {
    case Left(result) => Future.successful(result)
    case Right(authInfo) =>
      for {
        profile <- googleProvider.retrieveProfile(authInfo)
        user <- userService.save(profile)
        authenticator <- silhouette.env.authenticatorService.create(profile.loginInfo)
        value <- silhouette.env.authenticatorService.init(authenticator)
        result <- silhouette.env.authenticatorService.embed(value, Redirect("/dashboard"))
      } yield {
        silhouette.env.eventBus.publish(LoginEvent(user, request))
        result
      }
  }
}

多要素認証(2FA/TOTP)実装

// TOTP認証の設定
import play.silhouette.impl.providers.totp._

@Provides 
def provideTOTPProvider(): TOTPProvider = {
  new TOTPProvider(TOTPSettings(
    qrCodeURL = "https://chart.googleapis.com/chart?chs=200x200&chld=M%%7C0&cht=qr&chl=",
    issuer = "MyApp",
    keyLength = 32,
    window = 3
  ))
}

// 2FA有効化
def enableTwoFactor = silhouette.SecuredAction.async { implicit request =>
  val totpInfo = totpProvider.createCredentials(request.identity.userID.toString)
  
  for {
    _ <- totpCredentialsDAO.save(totpInfo)
    qrCodeURL = totpProvider.qrCodeURL(request.identity.email.getOrElse(""), totpInfo)
  } yield Ok(Json.obj(
    "qrCodeURL" -> qrCodeURL,
    "sharedSecret" -> totpInfo.sharedSecret
  ))
}

// TOTP検証
def verifyTOTP = silhouette.SecuredAction.async { implicit request =>
  VerifyTOTPForm.form.bindFromRequest.fold(
    _ => Future.successful(BadRequest),
    verificationCode => {
      totpCredentialsDAO.find(request.identity.userID).flatMap {
        case Some(totpInfo) =>
          if (totpProvider.verify(verificationCode, totpInfo)) {
            // 2FA認証成功
            Future.successful(Ok(Json.obj("verified" -> true)))
          } else {
            Future.successful(Unauthorized(Json.obj("verified" -> false)))
          }
        case None =>
          Future.successful(BadRequest(Json.obj("message" -> "TOTP not configured")))
      }
    }
  )
}

カスタム認証プロバイダー

// API Key認証プロバイダーの実装
import play.silhouette.api.Provider
import play.silhouette.api.util.{Credentials, ExtractableRequest}

case class APIKeyCredentials(apiKey: String) extends Credentials

class APIKeyProvider @Inject()(
  apiKeyService: APIKeyService
) extends Provider {
  
  override val id = APIKeyProvider.ID
  
  def authenticate[B](credentials: APIKeyCredentials)
    (implicit request: ExtractableRequest[B]): Future[LoginInfo] = {
    
    apiKeyService.validate(credentials.apiKey).map { userID =>
      LoginInfo(id, userID.toString)
    }.getOrElse {
      throw new InvalidCredentialsException("Invalid API key")
    }
  }
}

object APIKeyProvider {
  val ID = "apikey"
}

// API Key認証の使用
def apiEndpoint = silhouette.UnsecuredAction.async { implicit request =>
  request.headers.get("X-API-Key") match {
    case Some(apiKey) =>
      apiKeyProvider.authenticate(APIKeyCredentials(apiKey)).flatMap { loginInfo =>
        userService.retrieve(loginInfo).map {
          case Some(user) => Ok(Json.obj("data" -> "API response", "user" -> user.email))
          case None => Unauthorized
        }
      }.recover {
        case _: Exception => Unauthorized
      }
    case None =>
      Future.successful(Unauthorized(Json.obj("message" -> "API key required")))
  }
}

パスワードリセット機能

// パスワードリセットトークン
case class PasswordResetToken(
  id: UUID,
  userID: UUID,
  email: String,
  expirationTime: DateTime,
  isSignUp: Boolean = false
)

// パスワードリセット開始
def forgotPassword = silhouette.UnsecuredAction.async { implicit request =>
  ForgotPasswordForm.form.bindFromRequest.fold(
    _ => Future.successful(BadRequest),
    email => {
      userService.retrieve(LoginInfo(CredentialsProvider.ID, email)).flatMap {
        case Some(user) =>
          val token = PasswordResetToken(
            id = UUID.randomUUID(),
            userID = user.userID,
            email = email,
            expirationTime = DateTime.now.plusHours(1)
          )
          
          for {
            _ <- passwordResetTokenService.save(token)
            _ <- emailService.sendPasswordResetEmail(user, token)
          } yield Ok(Json.obj("message" -> "Reset email sent"))
          
        case None =>
          // セキュリティのため、ユーザーが存在しない場合も成功レスポンス
          Future.successful(Ok(Json.obj("message" -> "Reset email sent")))
      }
    }
  )
}

// パスワードリセット実行
def resetPassword(tokenID: String) = silhouette.UnsecuredAction.async { implicit request =>
  ResetPasswordForm.form.bindFromRequest.fold(
    _ => Future.successful(BadRequest),
    formData => {
      passwordResetTokenService.find(UUID.fromString(tokenID)).flatMap {
        case Some(token) if token.expirationTime.isAfterNow =>
          val passwordInfo = passwordHasherRegistry.current.hash(formData.password)
          
          for {
            _ <- passwordInfoDAO.update(LoginInfo(CredentialsProvider.ID, token.email), passwordInfo)
            _ <- passwordResetTokenService.remove(token.id)
          } yield Ok(Json.obj("message" -> "Password reset successful"))
          
        case Some(_) =>
          Future.successful(BadRequest(Json.obj("message" -> "Token expired")))
        case None =>
          Future.successful(BadRequest(Json.obj("message" -> "Invalid token")))
      }
    }
  )
}