Silhouette
認証ライブラリ
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公式ドキュメント
- Silhouette GitHub(アクティブフォーク)
- Silhouette Rocks(公式サイト)
- Silhouette リリース情報
- Scaladex - Silhouette
- 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")))
}
}
)
}