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 Framework 3.
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:
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
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
123456789101112131415
traitDTReader{implicitvaldateTimeReader:Reads[DateTime]=Reads{json=>json.validate[String]map{str=>DateTime.fromIsoDateTimeString(str).orElseThrow(()=>newException("Invalid date format"))}}implicitvallocalDateReader:Reads[LocalDate]=Reads{json=>json.validate[String].flatMap{str=>LocalDate.parse(str,DateTimeFormatter.ISO_DATE)match{caselocalDate:LocalDate=>JsSuccess(localDate)case_=>thrownewException("invalid date")}}}}
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
1234567891011121314151617181920212223
caseclassUser(id:Option[Long],email:String,name:String,lastName:String,password:Option[String]=None,dateOfBirth:LocalDate,dateOfCreation:Option[DateTime])extendsIdentity{/** * Generates login info from email * * @return login info */defloginInfo=LoginInfo(CredentialsProvider.ID,email)/** * Generates password info from password. * * @return password info */defpasswordInfo=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.
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).
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.
classUserTable(tag:Tag)extendsTable[User](tag,Some("play_silhouette"),"users")withDTCType{/** The ID column, which is the primary key, and auto incremented */defid=column[Option[Long]]("id",O.PrimaryKey,O.AutoInc,O.Unique)/** The name column */defname=column[String]("name")/** The email column */defemail=column[String]("email",O.Unique)/** The last name column */deflastName=column[String]("lastName")/** The password column */defpassword=column[Option[String]]("password")defdateOfBirth=column[LocalDate]("dateOfBirth")(localDateColumnType)defdateOfCreation=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)}
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
123456789101112131415161718
traitUserServiceextendsIdentityService[User]{/** * Saves a user. * * @param user The user to save. * @return The saved user. */defsave(user:User):Future[User]/** * Updates a user. * * @param user The user to update. * @return The updated user. */defupdate(user:User):Future[User]}
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
1234567891011121314151617181920212223242526
traitUserDAO{/** * 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. */deffind(loginInfo:LoginInfo):Future[Option[User]]/** * Saves a user. * * @param user The user to save. * @return The saved user. */defsave(user:User):Future[User]/** * Updates a user. * * @param user The user to update. * @return The saved user. */defupdate(user:User):Future[User]}
Let's create an implementation of the DelegableAuthInfoDAO[PasswordInfo] property that manages the storage and retrieval
of password information (PasswordInfo) associated with user authentication.
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.
classCustomSecuredErrorHandler@Inject()(valmessagesApi:MessagesApi)extendsSecuredErrorHandlerwithI18nSupport{/** * 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. */overridedefonNotAuthenticated(implicitrequest:RequestHeader)={valjsonResponse=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. */overridedefonNotAuthorized(implicitrequest:RequestHeader)={valjsonResponse=Json.obj("code"->403,"message"->"User is authenticated but not authorized.")Future.successful(Forbidden(jsonResponse))}}
123456789101112131415161718
classCustomUnsecuredErrorHandlerextendsUnsecuredErrorHandler{/** * 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. */overridedefonNotAuthorized(implicitrequest:RequestHeader)={valjsonResponse=Json.obj("code"->403,"message"->"User is authenticated but not authorized.")Future.successful(Forbidden(jsonResponse))}}
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.
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.
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.
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
The providePasswordDAO purpose is to create and provide an instance of DelegableAuthInfoDAO[PasswordInfo] used in
the Silhouette library to handle password-related authentication information.
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).
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.
classSignUpController@Inject()(components:SilhouetteControllerComponents)(implicitex:ExecutionContext)extendsSilhouetteController(components){/** * Handles sign up request * * @return The result to display. */defsignUp=UnsecuredAction.async{implicitrequest:Request[AnyContent]=>implicitvallang:Lang=supportedLangs.availables.headrequest.body.asJson.flatMap(_.asOpt[User])match{caseSome(newUser)ifnewUser.password.isDefined=>userService.retrieve(LoginInfo(CredentialsProvider.ID,newUser.email)).flatMap{caseSome(_)=>Future.successful(Conflict(JsString(messagesApi("user.already.exist"))))caseNone=>valauthInfo=passwordHasherRegistry.current.hash(newUser.password.get)valuser=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 to
existing applications, especially those heavily reliant on Akka.
The complete source code of the example could be found here.
If you're looking for a developer or considering starting a new project,
we are always ready to help!