Skip to content

Commit

Permalink
419 authenticator against keycloak (#420)
Browse files Browse the repository at this point in the history
* follow cilogon example

* add keycloak

* keycloak is working now

* changelog

* revert accidental change

* replace the default conf to clowder

* typo

* use the correct group

* check roles also

* provide examples of filtering by grouops and roles

---------

Co-authored-by: Luigi Marini <[email protected]>
  • Loading branch information
longshuicy and lmarini authored Sep 11, 2023
1 parent 2d8b929 commit 5d7c19e
Show file tree
Hide file tree
Showing 5 changed files with 117 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Extractors can now specify an extractor_key and an owner (email address) when sending a
registration or heartbeat to Clowder that will restrict use of that extractor to them.
- Added a dropdown menu to select all spaces, your spaces and also the spaces you have access to. [#374](https://github.com/clowder-framework/clowder/issues/374)
- Keycloak provider with secure social [#419](https://github.com/clowder-framework/clowder/issues/419)
- Documentation on how to do easy testing of pull requests

## Fixed
Expand Down
103 changes: 103 additions & 0 deletions app/services/KeycloakProvider.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package services

import play.api.libs.ws.WS
import play.api.{Application, Logger}
import play.api.libs.json.JsObject
import securesocial.core._
import scala.collection.JavaConverters._


/**
* A Keycloak OAuth2 Provider
*/
class KeycloakProvider(application: Application) extends OAuth2Provider(application) {
val Error = "error"
val Message = "message"
val Type = "type"
val Sub = "sub"
val Name = "name"
val GivenName = "given_name"
val FamilyName = "family_name"
// todo: picture wont work
val Picture = "picture"
val Email = "email"
val Groups = "groups"

override def id = KeycloakProvider.Keycloak

def fillProfile(user: SocialUser): SocialUser = {
val UserInfoApi = loadProperty("userinfoUrl").getOrElse(throwMissingPropertiesException())
val accessToken = user.oAuth2Info.get.accessToken
val promise = WS.url(UserInfoApi.toString).withHeaders(("Authorization", "Bearer " + accessToken)).get()

try {
val response = awaitResult(promise)
val me = response.json
Logger.debug("Got back from Keycloak : " + me.toString())
(me \ Error).asOpt[JsObject] match {
case Some(error) =>
val message = (error \ Message).as[String]
val errorType = ( error \ Type).as[String]
Logger.error("[securesocial] error retrieving profile information from Keycloak. Error type = %s, message = %s"
.format(errorType,message))
throw new AuthenticationException()
case _ =>
val userId = (me \ Sub).as[String]
val firstName = (me \ GivenName).asOpt[String]
val lastName = (me \ FamilyName).asOpt[String]
val fullName = (me \ Name).asOpt[String]
val avatarUrl = ( me \ Picture).asOpt[String]
val email = ( me \ Email).asOpt[String]
val groups = ( me \ Groups).asOpt[List[String]]
val roles = ( me \ "resource_access" \ "account" \ "roles").asOpt[List[String]]
(application.configuration.getList("securesocial.keycloak.groups"), groups) match {
case (Some(conf), Some(keycloak)) => {
val conflist = conf.unwrapped().asScala.toList
if (keycloak.intersect(conflist).isEmpty) {
throw new AuthenticationException()
}
}
case (Some(_), None) => throw new AuthenticationException()
case (None, _) => Logger.debug("[securesocial] No check needed for groups")
}
(application.configuration.getList("securesocial.keycloak.roles"), roles) match {
case (Some(conf), Some(keycloak)) => {
val conflist = conf.unwrapped().asScala.toList
if (keycloak.intersect(conflist).isEmpty) {
throw new AuthenticationException()
}
}
case (Some(_), None) => throw new AuthenticationException()
case (None, _) => Logger.debug("[securesocial] No check needed for roles")
}
user.copy(
identityId = IdentityId(userId, id),
firstName = firstName.getOrElse(""),
lastName = lastName.getOrElse(""),
fullName = fullName.getOrElse({
if (firstName.isDefined && lastName.isDefined) {
firstName.get + " " + lastName.get
} else if (firstName.isDefined) {
firstName.get
} else if (lastName.isDefined) {
lastName.get
} else {
""
}
}),
avatarUrl = avatarUrl,
email = email
)
}
} catch {
case e: Exception => {
Logger.error( "[securesocial] error retrieving profile information from Keycloak", e)
throw new AuthenticationException()
}
}
}
}

object KeycloakProvider {
val Keycloak = "keycloak"
}
1 change: 1 addition & 0 deletions conf/play.plugins
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#10050:services.CrowdProvider
#10051:services.CILogonProvider
#10052:services.LdapProvider
#10053:services.KeycloakProvider
#10090:services.MailerPlugin
#10091:services.AdminsNotifierPlugin
#10100:services.TempFilesPlugin
Expand Down
12 changes: 12 additions & 0 deletions conf/securesocial.conf
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,18 @@ securesocial {
#groups=["cn=org_isda,ou=Groups,dc=ncsa,dc=illinois,dc=edu"]
}

keycloak {
authorizationUrl="http://localhost:8080/keycloak/realms/clowder/protocol/openid-connect/auth"
accessTokenUrl="http://localhost:8080/keycloak/realms/clowder/protocol/openid-connect/token"
userinfoUrl="http://localhost:8080/keycloak/realms/clowder/protocol/openid-connect/userinfo"
clientId=your_client_id
clientSecret=your_client_secret
scope="profile email roles"
# Example of filtering by groups and/or roles
# groups=["group1", "group2"]
# roles=["role1", "role2"]
}

ldap {
url="http://localhost/ldap"
hostname="ldap.example.com"
Expand Down
Binary file added public/securesocial/images/providers/keycloak.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 5d7c19e

Please sign in to comment.