4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ZOZOテクノロジーズ #3Advent Calendar 2020

Day 18

PAC4J : Security library for Play framework and Scala

Last updated at Posted at 2020-12-10

Introduction

At first, Pac4 is developed as a Java library but can be use on Scala without any problem. It's also compatible with many different frameworks like Play, Spring ...

Here I will introduce how to implement this library in the Play Framework using Scala.

Dependencies

I am using sbt for building :

build.sbt
libraryDependencies += "org.pac4j" % "play-pac4j_2.12" % "10.0.2"

Next there are different modules for defining client and authenticator.
Client maybe direct(internal) or indirect(external)

But I will talk mainly about direct client.
Let's make a first Basic authentication and next a Bearer token authentication.
Login and password will be send as a Basic auth that will create a token and this token will be used for calling other api that required a Bearer authentication.

So we need 2 libraries :

build.sbt
libraryDependencies += "org.pac4j" % pac4j-http" % "4.0.0"
libraryDependencies += "org.pac4j" % pac4j-jwt" % "4.0.0"

Security Module

The security module define the different clients.
So at first let's enable a play modules :

application.conf
play.modules.enabled += "common.modules.SecurityModule"

And next define our module :

SecurityModule.scala

class SecurityModule(environment: Environment, configuration: Configuration) extends AbstractModule{
  val executionContext: ExecutionContext =
    new ExecutionContext {
      override def execute(runnable: Runnable): Unit = global.execute(runnable)
      override def reportFailure(cause: Throwable): Unit = global.reportFailure(cause)
    }
  override def configure(): Unit = {
    bind(classOf[PlaySessionStore]).to(classOf[PlayCacheSessionStore])
    bind(classOf[SecurityComponents]).to(classOf[DefaultSecurityComponents])
    bind(classOf[Pac4jScalaTemplateHelper[CommonProfile]])
  }
  // Use for email / Password authentication action
  @Provides
  def provideDirectBasicAuthClient: DirectBasicAuthClient = {
    val passwordAuthenticator = new PasswordAuthenticator(configuration)
    val basicClient = new DirectBasicAuthClient(passwordAuthenticator)
    basicClient
  }

  // Use for user token authentication
  @Provides
  def provideDirectBearerAuthClient: DirectBearerAuthClient = {
    val userAuthenticator = new UserAuthenticator(configuration)
    val bearerClient = new DirectBearerAuthClient(userAuthenticator)
    bearerClient
  }

  @Provides
  def provideConfig(provideDirectBearerAuthClient: DirectBearerAuthClient, provideDirectBasicAuthClient: DirectBasicAuthClient): Config = {
    val clients = new Clients("/callback", provideDirectBearerAuthClient, provideDirectBasicAuthClient, new AnonymousClient())
    val config = new Config(clients)
    config.addAuthorizer("role_authorizer", new RequireAnyRoleAuthorizer[CommonProfile]())
    config.setHttpActionAdapter(new PlayHttpActionAdapter())
    config
  }
}

In other term, this module read the configuration of the incoming url call,
if the header contains Authentication: Basic ... , the class PasswordAuthenticator will be called.
if the header contains Authentication: Bearer ... , the class UserAuthenticator will be called.

Authenticator

The authenticator is here to validate credentials and next generate a user profile.

Basic Auth

Let's define our authenticator.

PasswordAuthenticator.scala
class PasswordAuthenticator @Inject() (configuration: Configuration) extends Authenticator[UsernamePasswordCredentials]{
  @Override
  override def validate(credentials: UsernamePasswordCredentials, context: WebContext): Unit = {
    // Validation
    if (credentials == null) throw new CredentialsException("No credential")
    val username = credentials.getUsername
    val password = credentials.getPassword
    if (CommonHelper.isBlank(username)) throw new CredentialsException("Username cannot be blank")
    if (CommonHelper.isBlank(password)) throw new CredentialsException("Password cannot be blank")

    // Login Password verification - you can implement a DB verification
    if (username != "test" && password != "test") throw new CredentialsException("User not found")

    // User is verified, we create a profile
    val profile = new CommonProfile
    profile.setId(1)
    profile.setRoles(Set(Role.USER.role).asJava)
    profile.addAttribute("TEST",123)  // We can add any additional attributes
    credentials.setUserProfile(profile)
  }
}

Here the authenticator just decode the Basic token and check username and password before creating a user profile.
The user profile can be read from the controller.

Let's define our urls:

routes.conf
POST        /login                      controllers.AuthController.login      ## Basic Auth required
GET         /get_profile                controllers.AuthController.profile    ## Bearer Auth required

Next define our controller:

AuthController.scala
class AuthController @Inject()(
                                val controllerComponents: SecurityComponents,
                                configuration: Configuration,
                                implicit val executionContext: ExecutionContext,
                                implicit val pac4jTemplateHelper: Pac4jScalaTemplateHelper[CommonProfile]
                              )  extends Security[CommonProfile] {

  // Secure means the login function should use the security module.
  // DirectBasicAuthClient is the only client method allowed
  // Authorizers define witch user are allowed to access it. Here all the profile are allowed
  def login() = Secure("DirectBasicAuthClient", authorizers = "role_authorizer") { request =>
    // User profile can be accessed with request.profiles.head (CommonProfile)
    // Now we create a JWT token with an expire time
    val signatureToken="I want one token please, I am a normal user and I am a good guy"
    val tokenExpireDays=1
    val (token, expireAt) = generateToken(request.profiles, signatureToken, tokenExpireDays)

    Ok(Json.obj(
      "token" -> token,
      "expireAt" -> expireAt
    ))
  }

  // Generate a JWT Token based on user profile
  def generateToken(profiles: List[CommonProfile], signature: String, expire: Int): (String, ZonedDateTime) = {
    val generator = new JwtGenerator[CommonProfile](new SecretSignatureConfiguration(signature))
    val expireAt = ZonedDateTime.now().plusDays(expire)
    generator.setExpirationTime(Date.from(expireAt.toInstant))
    var token: String = ""
    if (CommonHelper.isNotEmpty(profiles.asJava)) {
      try {
        token = generator.generate(profiles.asJava.get(0))
      } catch {
        case e:Throwable => {
          throw RuntimeException(s"Error binding timezone.(columnLabel: }")
        }
      }
    }
    (token,expireAt)
  }

}

login() can only be called from a Basic Authentication and with an existing user.

Bearer Auth

Let's define our authenticator.

UserAuthenticator.scala
class UserAuthenticator @Inject()(configuration: Configuration) extends JwtAuthenticator {
  
  val signature="I want one token please, I am a normal user and I am a good guy"

  @Override
  override def validate(credentials: TokenCredentials, context: WebContext): Unit = {
      super.addSignatureConfiguration(new SecretSignatureConfiguration(signature))
      super.validate(credentials, context)
      val commonProfile = credentials.getUserProfile()
      if (commonProfile != null) {
            commonProfile.setClientName("Toto") // Add some attributes...
            commonProfile.addAttribute("new attribute", "test") // Add some attributes...
            credentials.setUserProfile(commonProfile)
      }
   }
}

When calling super.validate() the signature is used to decode and validate the token, if the token is not valid a credential exception is throw. An expire token will also return a credential exception.
Once the token is read, a commonProfile is created and can be read in the Controller.
Let's define an additional method to our controller.

AuthController.scala
  // Secure means the login function should use the security module.
  // DirectBearerAuthClient is the only client method allowed
  // Authorizers define witch user are allowed to access it. Here all the profile are allowed
  def profile() = Secure("DirectBearerAuthClient", authorizers = "role_authorizer") { request =>
    ...
    // Now we can display our user profile
    Ok(Json.obj(
      "id" -> request.profiles.head.getId,
      "name" -> request.profiles.head.getClientName,
      "other_attribute" -> request.profiles.head.getAttribute("new attribute")
    ))
  }

Replacing easily an auth client

Let's say we want to transmit the token with a cookie and not a bearer auth.
We will add :

SecurityModule.scala
  // Use for cookie token authentication
  @Provides
  def provideCookieClient: CookieClient = {
    val userAuthenticator = new UserAuthenticator(configuration)
    val cookieClient = new CookieClient("project-token",userAuthenticator)
    cookieClient
  }

   @Provides
  def provideConfig(provideDirectBearerAuthClient: DirectBearerAuthClient, provideDirectBasicAuthClient: DirectBasicAuthClient, provideCookieClient: CookieClient): Config = {
    val clients = new Clients("/callback", provideDirectBearerAuthClient, provideDirectBasicAuthClient, provideCookieClient, new AnonymousClient())
    val config = new Config(clients)
    config.addAuthorizer("role_authorizer", new RequireAnyRoleAuthorizer[CommonProfile]())
    config.setHttpActionAdapter(new PlayHttpActionAdapter())
    config
  }

If a cookie named "project-token" is present it will trigger the UserAuthenticator.
Next modify our controller

AuthController.scala
def profile() = Secure("CookieClient", authorizers = "role_authorizer") {
...

If you want to set the cookie :

AuthController.scala
  def login() = Secure("DirectBasicAuthClient", authorizers = "role_authorizer") { request =>
...
    Ok(json).withCookies(Cookie(
            name = "project-token",
            value = token,
            maxAge = expireAt,
            secure = request.connection.secure
          ))
}

We can also provide 2 clients:

AuthController.scala
def profile() = Secure("DirectBearerAuthClient,CookieClient", authorizers = "role_authorizer") {
...

Indirect Client

We can also use indirect client like OpenID Connect

build.sbt
libraryDependencies += "org.pac4j" % "pac4j-oidc" % "4.0.0" exclude("commons-io", "commons-io")
SecurityModule.scala
  @Provides
  def provideOidcClient: OidcClient[OidcProfile, OidcConfiguration] = {
    val oidcConfiguration = new OidcConfiguration()
    oidcConfiguration.setClientId(client_id)
    oidcConfiguration.setSecret(client_secret)
    oidcConfiguration.setDiscoveryURI(discoveryURI)
    oidcConfiguration.setExpireSessionWithToken(true)
    //oidcConfiguration.addCustomParam("prompt", "consent")

    val oidcClient = new OidcClient[OidcProfile, OidcConfiguration](oidcConfiguration)
    oidcClient.addAuthorizationGenerator((context: WebContext, profile: OidcProfile) => {
      profile.addRole("ROLE_DEV")
      profile
    })
    oidcClient
  }

  @Provides
  def provideOidcHeaderClient: HeaderClient = {
    val oidcConfiguration = new OidcConfiguration()
    oidcConfiguration.setClientId(client_id)
    oidcConfiguration.setSecret(client_secret)
    oidcConfiguration.setDiscoveryURI(discoveryURI)
    //oidcConfiguration.addCustomParam("prompt", "consent")

    val authenticator = new UserInfoOidcAuthenticator(oidcConfiguration)
    val headerClient = new HeaderClient("Authorization", "Bearer ", authenticator)
    headerClient
  }

  @Provides
  def provideConfig(oidcHeaderClient: HeaderClient, oidcClient: OidcClient[OidcProfile, OidcConfiguration]): Config = {
    val clients = new Clients(base_url + "/callback", oidcHeaderClient, oidcClient, new AnonymousClient())

    val config = new Config(clients)
    config.addAuthorizer("role_authorizer", new RequireAnyRoleAuthorizer[Nothing]("ROLE_DEV"))
    config.setHttpActionAdapter(new PlayHttpActionAdapter())
    config
  }

Next we can define our controller

AuthController.scala
def index() = Secure("OidcClient", authorizers = "role_authorizer")) {
...

def profile() = Secure("HeaderClient") {
...

Here the profile can be access with request.profiles.head and the profile is store in an OidcProfile Object.

When you try to access the url without Bearer token you will be redirected to the external login url page discoveryURI

Conclusion

pac4j is a really an useful library to manage easily authentication, role and user profile on a project.
It can be use in many different framework and language.

:thumbsup:

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?