Migrating to Play Framework 3: Handling Changes from Akka to Apache Pekko

ava-s-anastasiia-odyntsova

Previously, Dmitriy Chuprina provided guidance on building a basic application demonstrating authentication and authorization processes utilizing JSON Web Token (JWT), but in September 2022, Lightbend Inc. changed the Akka license model from the Apache 2.0 license to the Business Source License (BSL) 1.1 and now if you want to use the latest versions of Akka in a Play 2.x application in a way not described in the official Play Framework documentation, you may already need to obtain a license. As a result, in October 2023, a new version of Play Framework 3 was released, which uses Apache Pekko instead of Akka and Akka HTTP. So here we will analyze how to migrate a project to Play Framework3.

Technologies:

  • Scala 2.13
  • Play Framework 3.0.1
  • Silhouette 10.0.0
  • Slick 3.4.1
  • PostgreSQL
  • Guice
  • Play evolutions

User model:

Data Model Schema

Dependencies used:

build.sbt

name := "auth-with-play-silhouette-example"

version := "1.0-SNAPSHOT"

scalaVersion := "2.13.12"

resolvers += Resolver.jcenterRepo

resolvers += "Sonatype snapshots" at "https://oss.sonatype.org/content/repositories/snapshots/"

val playSilhouetteVersion = "10.0.0"
val slickVersion = "3.4.1"
val playSlickVersion = "6.0.0"

libraryDependencies ++= Seq(
  "org.playframework.silhouette" %% "play-silhouette" % playSilhouetteVersion,
  "org.playframework.silhouette" %% "play-silhouette-password-bcrypt" % playSilhouetteVersion,
  "org.playframework.silhouette" %% "play-silhouette-persistence" % playSilhouetteVersion,
  "org.playframework.silhouette" %% "play-silhouette-crypto-jca" % playSilhouetteVersion,
  "net.codingwell" %% "scala-guice" % "6.0.0",
  "com.typesafe.slick" %% "slick" % slickVersion,
  "com.typesafe.slick" %% "slick-hikaricp" % slickVersion,
  "org.playframework" %% "play-slick" % playSlickVersion,
  "org.playframework" %% "play-slick-evolutions" % playSlickVersion,
  //it's org.postgresql.ds.PGSimpleDataSource dependency
  "org.postgresql" % "postgresql" % "42.7.1",
  guice,
  filters
)

lazy val root = (project in file(".")).enablePlugins(PlayScala)Code language: JavaScript (javascript)

project/plugins.sbt

logLevel := Level.Warn

addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.0")

addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.8.3")

ThisBuild / libraryDependencySchemes ++= Seq(
 "org.scala-lang.modules" %% "scala-xml" % VersionScheme.Always
)Code language: JavaScript (javascript)

In project/build.properties, you also need to replace the sbt version with sbt.version=1.9.6. Newest versions of Play (2.9 / 3.0) only supports sbt 1.9 or newer.

In the conf/db.conf file, you need to set the database properties for Slick as follows:

slick {
   dbs {
       default {
           profile="slick.jdbc.PostgresProfile$"
           driver="slick.driver.PostgresDriver$"

           db {
               driver="org.postgresql.Driver"
               url="jdbc:postgresql://localhost:5432/your_database_name"
               user="your_user"
               password="your_password"
           }
       }
   }
}Code language: JavaScript (javascript)

Also, in the same file, set the settings for evolutions, which will enable evolutions by default in your application.

play.evolutions {
   enabled=true
   db.default.schema ="public"
}Code language: PHP (php)

In order to disable the implementation of our own error handling logic for secured and unsecured actions, disable the following modules SecuredErrorHandlerModule, UnsecuredErrorHandlerModule as follows

conf/application.conf

play.modules.disabled += "play.silhouette.api.actions.SecuredErrorHandlerModule"
play.modules.disabled += "play.silhouette.api.actions.UnsecuredErrorHandlerModule"Code language: JavaScript (javascript)

Given the inability to utilize joda.dataTime, we aim to leverage ‘apache pekko’ (org.apache.pekko.http.javadsl.model.DateTime).
Our objective involves appending two fresh fields, dateOfCreation and dateOfCreation, to the custom object.
Hence, we’re tasked with crafting a Reader to interpret DateTime and LocalDate objects from JSON strings.

models/DTReader

trait DTReader {
 implicit val dateTimeReader: Reads[DateTime] = Reads { json =>
   json.validate[String] map { str =>
     DateTime.fromIsoDateTimeString(str).orElseThrow(() => new Exception("Invalid date format"))
   }
 }
 implicit val localDateReader: Reads[LocalDate] = Reads { json =>
   json.validate[String].flatMap { str =>
     LocalDate.parse(str, DateTimeFormatter.ISO_DATE) match {
       case localDate: LocalDate => JsSuccess(localDate)
       case _ => throw new Exception("invalid date")
     }
   }
 }
}Code language: PHP (php)

If we want to use the User instance as an entity to be authenticated, the user model must extend the Identity property in Silhouette.

model/User

case class User(
                id: Option[Long],
                email: String,
                name: String,
                lastName: String,
                password: Option[String] = None,
                dateOfBirth: LocalDate,
                dateOfCreation: Option[DateTime]) extends Identity {

 /**
  * Generates login info from email
  *
  * @return login info
  */
 def loginInfo = LoginInfo(CredentialsProvider.ID, email)

 /**
  * Generates password info from password.
  *
  * @return password info
  */
 def passwordInfo = PasswordInfo(BCryptSha256PasswordHasher.ID, password.get)
}

We define implicit JSON readers and writers for the User class, inheriting features from the DTReader for processing
DateTime and LocalDate conversions for serializing and deserializing JSON.

model/User

object User extends DTReader {

 import play.api.libs.json._

 implicit val userReads: Reads[User] = Json.reads[User]
 implicit val creatureWrites: Writes[User] = new Writes[User] {
   def writes(c: User): JsValue = Json.obj(
     "name" -> c.name,
     "lastName" -> c.lastName,
     "dateOfBirth" -> c.dateOfBirth.toString(),
     "email" -> c.email,
     "dateOfCreation" -> c.dateOfCreation.get.toString
   )
 }
}Code language: JavaScript (javascript)

Let’s create a DTCType for implicit conversion between Scala types (LocalDate and DateTime) and their corresponding SQL types (java.sql.Date and java.sql.Timestamp).

tables/DTCType

trait DTCType {
 implicit val localDateColumnType: BaseColumnType[LocalDate] =
   MappedColumnType.base[LocalDate, java.sql.Date](
     ld => java.sql.Date.valueOf(ld),
     sqlDate => sqlDate.toLocalDate
   )

 implicit val dateTimeColumnType: JdbcType[DateTime] with BaseTypedType[DateTime] =
   MappedColumnType.base[DateTime, java.sql.Timestamp](
     dt => new java.sql.Timestamp(dt.clicks()),
     ts => DateTime.create(ts.getTime)
   )
}Code language: JavaScript (javascript)

Now, let’s create a Slick table mapping for the User entity. This table mapping includes the DTCType attribute, which provides implicit conversion of column types for the LocalDate and DateTime types.

tables/UserTable

class UserTable(tag: Tag) extends Table[User](tag, Some("play_silhouette"), "users") with DTCType {

 /** The ID column, which is the primary key, and auto incremented */
 def id = column[Option[Long]]("id", O.PrimaryKey, O.AutoInc, O.Unique)

 /** The name column */
 def name = column[String]("name")

 /** The email column */
 def email = column[String]("email", O.Unique)

 /** The last name column */
 def lastName = column[String]("lastName")

 /** The password column */
 def password = column[Option[String]]("password")

 def dateOfBirth = column[LocalDate]("dateOfBirth")(localDateColumnType)

 def dateOfCreation = column[DateTime]("dateOfCreation")(dateTimeColumnType)

 /**
  * This is the table's default "projection".
  *
  * It defines how the columns are converted to and from the User object.
  *
  * In this case, we are simply passing the id, name, email and password parameters to the User case classes
  * apply and unapply methods.
  */
 def * = (id, email, name, lastName, password, dateOfBirth, dateOfCreation.?).<> ((User.apply _).tupled, User.unapply)
}Code language: HTML, XML (xml)

The * method defines the default table projection, specifying how columns are mapped to the User case class. It uses the <> operator to map columns to the apply and unapply methods of the User case class, which allows Slick to convert database rows to User objects and vice versa.

The UserService trait appears to define an abstract service for managing user-related operations, extending IdentityService[User], which is used in authentication and authorization contexts in Play Framework with Silhouette.

services/UserService

trait UserService extends IdentityService[User] {

 /**
  * Saves a user.
  *
  * @param user The user to save.
  * @return The saved user.
  */
 def save(user: User): Future[User]

 /**
  * Updates a user.
  *
  * @param user The user to update.
  * @return The updated user.
  */
 def update(user: User): Future[User]
}Code language: PHP (php)

Let’s develop an abstraction for a data access object (DAO) responsible for processing operations related to user persistence. This feature will describe methods for retrieving, saving, and updating user data.

services/UserService

trait UserDAO {

 /**
  * Finds a user by its login info.
  *
  * @param loginInfo The login info of the user to find.
  * @return The found user or None if no user for the given login info could be found.
  */
 def find(loginInfo: LoginInfo): Future[Option[User]]

 /**
  * Saves a user.
  *
  * @param user The user to save.
  * @return The saved user.
  */
 def save(user: User): Future[User]

 /**
  * Updates a user.
  *
  * @param user The user to update.
  * @return The saved user.
  */
 def update(user: User): Future[User]
}Code language: PHP (php)

Let’s create an implementation of the DelegableAuthInfoDAO[PasswordInfo] property that manages the storage and retrieval of password information (PasswordInfo) associated with user authentication.

daos/PasswordInfoImpl

class PasswordInfoImpl @Inject() (userDAO: UserDAO)(implicit val classTag: ClassTag[PasswordInfo], ec: ExecutionContext)
 extends DelegableAuthInfoDAO[PasswordInfo] {

 /**
  * Finds passwordInfo for specified loginInfo
  *
  * @param loginInfo user's email
  * @return user's hashed password
  */
 override def find(loginInfo: LoginInfo): Future[Option[PasswordInfo]] = userDAO.find(loginInfo).map(_.map(_.passwordInfo))

 /**
  * Adds new passwordInfo for specified loginInfo
  *
  * @param loginInfo user's email
  * @param passwordInfo user's hashed password
  */
 override def add(loginInfo: LoginInfo, passwordInfo: PasswordInfo): Future[PasswordInfo] = update(loginInfo, passwordInfo)

 /**
  * Updates passwordInfo for specified loginInfo
  *
  * @param loginInfo user's email
  * @param passwordInfo user's hashed password
  */
 override def update(loginInfo: LoginInfo, passwordInfo: PasswordInfo): Future[PasswordInfo] = userDAO.find(loginInfo).flatMap {
   case Some(user) => userDAO.update(user.copy(password = Some(passwordInfo.password))).map(_.passwordInfo)
   case None => Future.failed(new Exception("user not found"))
 }

 /**
  * Adds new passwordInfo for specified loginInfo
  *
  * @param loginInfo user's email
  * @param passwordInfo user's hashed password
  */
 override def save(loginInfo: LoginInfo, passwordInfo: PasswordInfo): Future[PasswordInfo] = update(loginInfo, passwordInfo)

 /**
  * Removes passwordInfo for specified loginInfo
  *
  * @param loginInfo user's email
  */
 override def remove(loginInfo: LoginInfo): Future[Unit] = update(loginInfo, PasswordInfo("", "")).map(_ => ())
}

Due to our requirement for sending a JSON object containing an error code and a descriptive message in the event of Unauthorized and Forbidden errors, we have disabled the default modules – SecuredErrorHandlerModule and UnsecuredErrorHandlerModule – at the project’s outset. Now, we aim to implement our custom logic to handle these error scenarios.

class CustomSecuredErrorHandler @Inject() (val messagesApi: MessagesApi) extends SecuredErrorHandler with I18nSupport {
 /**
  * Called when a user is not authenticated.
  *
  * As defined by RFC 2616, the status code of the response should be 401 Unauthorized.
  *
  * @param request The request header.
  * @return The result to send to the client.
  */
 override def onNotAuthenticated(implicit request: RequestHeader) = {
   val jsonResponse = Json.obj(
     "code" -> 401,
     "message" -> "Authentication failed. Given policy has not granted."
   )
   Future.successful(Unauthorized(jsonResponse))
 }

 /**
  * Called when a user is authenticated but not authorized.
  *
  * As defined by RFC 2616, the status code of the response should be 403 Forbidden.
  *
  * @param request The request header.
  * @return The result to send to the client.
  */
 override def onNotAuthorized(implicit request: RequestHeader) = {
   val jsonResponse = Json.obj(
     "code" -> 403,
     "message" -> "User is authenticated but not authorized."
   )
   Future.successful(Forbidden(jsonResponse))
 }
}
class CustomUnsecuredErrorHandler extends UnsecuredErrorHandler {

 /**
  * Called when a user is authenticated but not authorized.
  *
  * As defined by RFC 2616, the status code of the response should be 403 Forbidden.
  *
  * @param request The request header.
  * @return The result to send to the client.
  */
 override def onNotAuthorized(implicit request: RequestHeader) = {
   val jsonResponse = Json.obj(
     "code" -> 403,
     "message" -> "User is authenticated but not authorized."
   )
   Future.successful(Forbidden(jsonResponse))
 }
}Code language: PHP (php)

Developing SilhouetteModule

SilhouetteModule is a Guice module that configures the dependencies associated with the Silhouette library in the Play Framework application to handle authentication and authorization.
Let’s set up bindings and configurations for dependency injection.

override def configure(): Unit = {
 bind[Silhouette[JWTEnvironment]].to[SilhouetteProvider[JWTEnvironment]]
 bind[UnsecuredErrorHandler].to[CustomUnsecuredErrorHandler]
 bind[SecuredErrorHandler].to[CustomSecuredErrorHandler]
 bind[IDGenerator].toInstance(new SecureRandomIDGenerator())
 bind[EventBus].toInstance(EventBus())
 bind[Clock].toInstance(Clock())
}Code language: JavaScript (javascript)

This method is a Guice provider method within the SilhouetteModule responsible for creating an instance of
Environment[JWTEnvironment] used in the Silhouette library for handling authentication.

@Provides
def provideEnvironment(
 userService: UserService,
 authenticatorService: AuthenticatorService[JWTAuthenticator],
 eventBus: EventBus): Environment[JWTEnvironment] = {

 Environment[JWTEnvironment](
   userService,
   authenticatorService,
   Seq(),
   eventBus
 )
}Code language: CSS (css)

Method provideAuthenticatorCrypter is responsible for creating an instance of the Crypter interface that handles encryption and decryption tasks associated with authentication tokens or sensitive information used in the Silhouette library.

@Provides
def provideAuthenticatorCrypter(configuration: Configuration): Crypter = {
 new JcaCrypter(JcaCrypterSettings(configuration.underlying.getString("play.http.secret.key")))
}Code language: CSS (css)

This provideAuthenticatorService method is a Guice provider method within the SilhouetteModule. Its purpose is to create and provide an instance of AuthenticatorService[JWTAuthenticator] used in the Silhouette library for managing JWT-based authentication

@Provides
def provideAuthenticatorService(
 crypter: Crypter,
 idGenerator: IDGenerator,
 configuration: Configuration,
 clock: Clock): AuthenticatorService[JWTAuthenticator] = {

 val encoder = new CrypterAuthenticatorEncoder(crypter)
 new JWTAuthenticatorService(JWTAuthenticatorSettings(
   fieldName = configuration.underlying.getString("silhouette.authenticator.headerName"),
   issuerClaim = configuration.underlying.getString("silhouette.authenticator.issuerClaim"),
   authenticatorExpiry = Duration(configuration.underlying.getString("silhouette.authenticator.authenticatorExpiry")).asInstanceOf[FiniteDuration],
   sharedSecret = configuration.underlying.getString("silhouette.authenticator.sharedSecret")

 ), None, encoder, idGenerator, clock)
}Code language: JavaScript (javascript)

The providePasswordDAO purpose is to create and provide an instance of DelegableAuthInfoDAO[PasswordInfo] used in the Silhouette library to handle password-related authentication information.

@Provides
def providePasswordDAO(userDao: UserDAO): DelegableAuthInfoDAO[PasswordInfo] = new PasswordInfoImpl(userDao)Code language: CSS (css)

The provideCredentialsProvider method is to create and provide an instance of the CredentialsProvider used in the Silhouette library for authenticating users via credentials (e.g., username/password).

@Provides
def provideCredentialsProvider(
 authInfoRepository: AuthInfoRepository,
 passwordHasherRegistry: PasswordHasherRegistry): CredentialsProvider = {

 new CredentialsProvider(authInfoRepository, passwordHasherRegistry)
}Code language: CSS (css)

SilhouetteController

By defining an abstract Silhouette controller, you can encapsulate the setup and access to Silhouette-related components, such as SecuredAction, UnsecuredAction, userService, authInfoRepository, and others. This abstract controller serves as a blueprint, allowing other controllers to inherit these Silhouette-specific functionalities without the need to duplicate code or redefine these components in every controller.

abstract class SilhouetteController(override protected val controllerComponents: SilhouetteControllerComponents)
        extends MessagesAbstractController(controllerComponents) with SilhouetteComponents with I18nSupport with Logging {

  def SecuredAction: SecuredActionBuilder[EnvType, AnyContent] = controllerComponents.silhouette.SecuredAction
  def UnsecuredAction: UnsecuredActionBuilder[EnvType, AnyContent] = controllerComponents.silhouette.UnsecuredAction

  def userService: UserService = controllerComponents.userService
  def authInfoRepository: AuthInfoRepository = controllerComponents.authInfoRepository
  def passwordHasherRegistry: PasswordHasherRegistry = controllerComponents.passwordHasherRegistry
  def clock: Clock = controllerComponents.clock
  def credentialsProvider: CredentialsProvider = controllerComponents.credentialsProvider

  def silhouette: Silhouette[EnvType] = controllerComponents.silhouette
  def authenticatorService: AuthenticatorService[AuthType] = silhouette.env.authenticatorService
  def eventBus: EventBus = silhouette.env.eventBus

Let’s also update the SignUpController

class SignUpController @Inject() (
 components: SilhouetteControllerComponents
)(implicit ex: ExecutionContext) extends SilhouetteController(components) {

 /**
  * Handles sign up request
  *
  * @return The result to display.
  */
 def signUp = UnsecuredAction.async { implicit request: Request[AnyContent] =>
   implicit val lang: Lang = supportedLangs.availables.head
   request.body.asJson.flatMap(_.asOpt[User]) match {
     case Some(newUser) if newUser.password.isDefined =>
       userService.retrieve(LoginInfo(CredentialsProvider.ID, newUser.email)).flatMap {
         case Some(_) =>
           Future.successful(Conflict(JsString(messagesApi("user.already.exist"))))
         case None =>
           val authInfo = passwordHasherRegistry.current.hash(newUser.password.get)
           val user = newUser.copy(password = Some(authInfo.password), dateOfCreation = Option(DateTime.now))
           userService.save(user).map(u => Ok(Json.toJson(u.copy(password = None))))
       }
     case _ => Future.successful(BadRequest(JsString(messagesApi("invalid.body"))))
   }
 }
}

No changes are needed for the remaining modules as they function appropriately without any adjustments.

Release of the Play Framework 3 is a significant milestone in the framework’s development as a leading OSS Web Framework for the Scala ecosystem. A public Akka fork, Apache Pekko, is adopted as its core asynchronous event system instead of Akka and Akka HTTP. This migration brings a simpler licensing model. However, it also necessitates some changes toexisting applications, especially those heavily reliant on Akka.

The complete source code of the example could be found here.