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 :
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 :
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 :
play.modules.enabled += "common.modules.SecurityModule"
And next define our module :
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.
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:
POST /login controllers.AuthController.login ## Basic Auth required
GET /get_profile controllers.AuthController.profile ## Bearer Auth required
Next define our controller:
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.
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.
// 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 :
// 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
def profile() = Secure("CookieClient", authorizers = "role_authorizer") {
...
If you want to set the cookie :
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:
def profile() = Secure("DirectBearerAuthClient,CookieClient", authorizers = "role_authorizer") {
...
Indirect Client
We can also use indirect client like OpenID Connect
libraryDependencies += "org.pac4j" % "pac4j-oidc" % "4.0.0" exclude("commons-io", "commons-io")
@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
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.