diff --git a/CHANGELOG.md b/CHANGELOG.md index ba104cea9..22577f118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/services/KeycloakProvider.scala b/app/services/KeycloakProvider.scala new file mode 100644 index 000000000..dab72cda6 --- /dev/null +++ b/app/services/KeycloakProvider.scala @@ -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" +} diff --git a/conf/play.plugins b/conf/play.plugins index 15e5e7ec4..faaae5ba4 100644 --- a/conf/play.plugins +++ b/conf/play.plugins @@ -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 diff --git a/conf/securesocial.conf b/conf/securesocial.conf index 9afb107b9..afa4fc019 100644 --- a/conf/securesocial.conf +++ b/conf/securesocial.conf @@ -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" diff --git a/public/securesocial/images/providers/keycloak.png b/public/securesocial/images/providers/keycloak.png new file mode 100644 index 000000000..7387557f7 Binary files /dev/null and b/public/securesocial/images/providers/keycloak.png differ