Silhouette

Authentication LibraryPlay FrameworkScalaOAuthJWTMulti-Factor AuthenticationSecurity

Authentication Library

Silhouette

Overview

Silhouette is a comprehensive authentication library for Play Framework applications. It supports multiple authentication methods including OAuth1, OAuth2, OpenID, CAS, Multi-Factor Authentication, TOTP, Credentials Authentication, Basic Authentication, and custom authentication schemes.

Details

Silhouette is a robust and comprehensive authentication library for Play Framework, continuing active development as of 2024. Originally developed in the mohiva/play-silhouette repository, it is now actively maintained in the playframework/play-silhouette fork, providing the latest snapshot builds. The Authenticator system, which is the heart of authentication, allows unified handling of various authentication methods including JWT tokens, session-based, and cookie-based authentication. It features asynchronous and non-blocking processing, internationalization support, and comprehensive test coverage, making it ideal for enterprise-level application development. While Silhouette 7.0 is the final version for Scala 2.12/2.13 and Play 2.8, the current fork provides continuous updates and snapshot releases.

Pros and Cons

Pros

  • Comprehensive Authentication Methods: Wide support for OAuth1/2, OpenID, CAS, JWT, TOTP, etc.
  • Play Framework Integration: Complete Play Framework integration for consistent development experience
  • Multi-Factor Authentication: Advanced security support with 2FA and TOTP
  • Asynchronous Processing: Fully asynchronous and non-blocking architecture
  • Customizable: Flexible implementation of custom authentication schemes
  • Internationalization: Multi-language support and localization
  • Test Support: Rich test utilities and coverage
  • Documentation: Extensive documentation and sample code

Cons

  • Learning Curve: Configuration and learning complexity due to rich features
  • Play Framework Dependency: Play Framework exclusive with strong framework dependency
  • Configuration Complexity: Complex initial setup and customization
  • Maintenance Status: Mixture of official and unofficial fork versions
  • Version Management: Stability considerations when using snapshot versions
  • Resource Consumption: Slight memory usage due to rich features
  • Dependencies: Dependencies on numerous external libraries

Key Links

Usage Examples

Basic Silhouette Configuration

// Dependency configuration in 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
)

// Configuration in application.conf
silhouette {
  # JWT authentication settings
  jwt.authenticator {
    headerName = "X-Auth-Token"
    issuerClaim = "play-silhouette"
    encryptSubject = true
    authenticatorExpiry = 12 hours
    sharedSecret = "changeme"
  }
  
  # OAuth settings
  oauth1TokenSecretProvider.cookieName="OAuth1TokenSecret"
  oauth1TokenSecretProvider.cookiePath="/"
  oauth1TokenSecretProvider.secureCookie=false // Set to true for HTTPS
  oauth1TokenSecretProvider.httpOnlyCookie=true
}

User Model and Identity Definition

// User model
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 implementation example
class UserServiceImpl extends UserService {
  def retrieve(loginInfo: LoginInfo): Future[Option[User]] = {
    // Retrieve user from database
    Future.successful(None) // Implementation example
  }
  
  def save(user: User): Future[User] = {
    // Save user to database
    Future.successful(user)
  }
}

Silhouette Environment Configuration

// Silhouette Environment configuration
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 container configuration example
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 Authentication Implementation

// JWT Authenticator configuration
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 authentication usage in controller
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 Implementation

// OAuth2 provider configuration
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 authentication controller
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
      }
  }
}

Multi-Factor Authentication (2FA/TOTP) Implementation

// TOTP authentication configuration
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
  ))
}

// Enable 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 verification
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 authentication success
            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")))
      }
    }
  )
}

Custom Authentication Provider

// API Key authentication provider implementation
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 authentication usage
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")))
  }
}

Password Reset Functionality

// Password reset token
case class PasswordResetToken(
  id: UUID,
  userID: UUID,
  email: String,
  expirationTime: DateTime,
  isSignUp: Boolean = false
)

// Initiate password reset
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 =>
          // Return success response for security even if user doesn't exist
          Future.successful(Ok(Json.obj("message" -> "Reset email sent")))
      }
    }
  )
}

// Execute password reset
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")))
      }
    }
  )
}