From 2159033393d6adc93771ffc2c03cdfdf37d217ae Mon Sep 17 00:00:00 2001 From: Chen Wang Date: Mon, 28 Aug 2023 09:06:09 -0500 Subject: [PATCH 01/10] follow cilogon example --- app/services/KeycloakProvider.scala | 92 +++++++++++++++++++++++++++++ conf/securesocial.conf | 10 ++++ 2 files changed, 102 insertions(+) create mode 100644 app/services/KeycloakProvider.scala diff --git a/app/services/KeycloakProvider.scala b/app/services/KeycloakProvider.scala new file mode 100644 index 000000000..754e572f9 --- /dev/null +++ b/app/services/KeycloakProvider.scala @@ -0,0 +1,92 @@ +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" + val Picture = "picture" + val Email = "email" + val Groups = "isMemberOf" + + + 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]] + (application.configuration.getList("securesocial.cilogon.groups"), groups) match { + case (Some(conf), Some(cilogon)) => { + val conflist = conf.unwrapped().asScala.toList + if (cilogon.intersect(conflist).isEmpty) { + throw new AuthenticationException() + } + } + case (Some(_), None) => throw new AuthenticationException() + case (None, _) => Logger.debug("[securesocial] No check needed for groups") + } + 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/securesocial.conf b/conf/securesocial.conf index 9afb107b9..0a98b08b1 100644 --- a/conf/securesocial.conf +++ b/conf/securesocial.conf @@ -161,6 +161,16 @@ securesocial { #groups=["cn=org_isda,ou=Groups,dc=ncsa,dc=illinois,dc=edu"] } + keycloak { + authBase="http://localhost" + authUrl="http://localhost/keycloak/realms/clowder/protocol/openid-connect/auth?client_id=clowder1&response_type=code" + oauth2SchemeAuthUrl="http://keycloak:8080/keycloak/realms/clowder/protocol/openid-connect/auth?client_id=clowder1&response_type=code" + authRegisterUrl="http://localhost/keycloak/realms/clowder/protocol/openid-connect/registrations?client_id=clowder1&response_type=code" + authTokenUrl="http://keycloak:8080/keycloak/realms/clowder/protocol/openid-connect/token" + authServerUrl="http://keycloak:8080/keycloak/" + keycloakBase="http://localhost/api" + } + ldap { url="http://localhost/ldap" hostname="ldap.example.com" From 8d2bcd6006e9686efea3e71a05b0acef52d3e658 Mon Sep 17 00:00:00 2001 From: Chen Wang Date: Mon, 28 Aug 2023 09:34:00 -0500 Subject: [PATCH 02/10] add keycloak --- conf/securesocial.conf | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/conf/securesocial.conf b/conf/securesocial.conf index 0a98b08b1..fb43f25d2 100644 --- a/conf/securesocial.conf +++ b/conf/securesocial.conf @@ -162,13 +162,12 @@ securesocial { } keycloak { - authBase="http://localhost" - authUrl="http://localhost/keycloak/realms/clowder/protocol/openid-connect/auth?client_id=clowder1&response_type=code" - oauth2SchemeAuthUrl="http://keycloak:8080/keycloak/realms/clowder/protocol/openid-connect/auth?client_id=clowder1&response_type=code" - authRegisterUrl="http://localhost/keycloak/realms/clowder/protocol/openid-connect/registrations?client_id=clowder1&response_type=code" - authTokenUrl="http://keycloak:8080/keycloak/realms/clowder/protocol/openid-connect/token" - authServerUrl="http://keycloak:8080/keycloak/" - keycloakBase="http://localhost/api" + authorizationUrl="http://localhost:8080/keycloak/realms/clowder1/protocol/openid-connect/auth?client_id=clowder1-backend&response_type=code" + accessTokenUrl="http://localhost:8080/keycloak/realms/clowder/protocol/openid-connect/token" + userinfoUrl="http://localhost:8080/keycloak/realms/clowder1/protocol/openid-connect/userinfo" + clientId=your_client_id + clientSecret=your_client_secret + scope="profile email roles" } ldap { From c1d530248679fe7cac764aec07eb99fe1ab213f7 Mon Sep 17 00:00:00 2001 From: Chen Wang Date: Mon, 28 Aug 2023 10:46:41 -0500 Subject: [PATCH 03/10] keycloak is working now --- conf/securesocial.conf | 4 ++-- .../securesocial/images/providers/keycloak.png | Bin 0 -> 2924 bytes 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 public/securesocial/images/providers/keycloak.png diff --git a/conf/securesocial.conf b/conf/securesocial.conf index fb43f25d2..9b3a3f5d9 100644 --- a/conf/securesocial.conf +++ b/conf/securesocial.conf @@ -162,8 +162,8 @@ securesocial { } keycloak { - authorizationUrl="http://localhost:8080/keycloak/realms/clowder1/protocol/openid-connect/auth?client_id=clowder1-backend&response_type=code" - accessTokenUrl="http://localhost:8080/keycloak/realms/clowder/protocol/openid-connect/token" + authorizationUrl="http://localhost:8080/keycloak/realms/clowder1/protocol/openid-connect/auth" + accessTokenUrl="http://localhost:8080/keycloak/realms/clowder1/protocol/openid-connect/token" userinfoUrl="http://localhost:8080/keycloak/realms/clowder1/protocol/openid-connect/userinfo" clientId=your_client_id clientSecret=your_client_secret diff --git a/public/securesocial/images/providers/keycloak.png b/public/securesocial/images/providers/keycloak.png new file mode 100644 index 0000000000000000000000000000000000000000..7387557f708367460a65444aef81fdcdf5fcc945 GIT binary patch literal 2924 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 literal 0 HcmV?d00001 From 0e2e73230885cb568be458a6bea6a7b4ddbe75a5 Mon Sep 17 00:00:00 2001 From: Chen Wang Date: Mon, 28 Aug 2023 11:21:09 -0500 Subject: [PATCH 04/10] changelog --- CHANGELOG.md | 1 + conf/play.plugins | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a201097e2..317c56803 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) ## Fixed - Updated lastModifiesDate when updating file or metadata to a dataset, added lastModified to UI [386](https://github.com/clowder-framework/clowder/issues/386) diff --git a/conf/play.plugins b/conf/play.plugins index 15e5e7ec4..2cf4ff6b8 100644 --- a/conf/play.plugins +++ b/conf/play.plugins @@ -20,7 +20,7 @@ 10020:services.SecureSocialEventListener #10050:services.CrowdProvider #10051:services.CILogonProvider -#10052:services.LdapProvider +#10052:services.KeycloakProvider #10090:services.MailerPlugin #10091:services.AdminsNotifierPlugin #10100:services.TempFilesPlugin From 8faf7dc3e138890d381b184a81f12a44c79643f7 Mon Sep 17 00:00:00 2001 From: Chen Wang Date: Mon, 28 Aug 2023 11:22:30 -0500 Subject: [PATCH 05/10] revert accidental change --- conf/play.plugins | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/conf/play.plugins b/conf/play.plugins index 2cf4ff6b8..faaae5ba4 100644 --- a/conf/play.plugins +++ b/conf/play.plugins @@ -20,7 +20,8 @@ 10020:services.SecureSocialEventListener #10050:services.CrowdProvider #10051:services.CILogonProvider -#10052:services.KeycloakProvider +#10052:services.LdapProvider +#10053:services.KeycloakProvider #10090:services.MailerPlugin #10091:services.AdminsNotifierPlugin #10100:services.TempFilesPlugin From f9b352bc3581c98e6474bff92894bca29d83551a Mon Sep 17 00:00:00 2001 From: Chen Wang Date: Mon, 28 Aug 2023 11:26:35 -0500 Subject: [PATCH 06/10] replace the default conf to clowder --- conf/securesocial.conf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/conf/securesocial.conf b/conf/securesocial.conf index 9b3a3f5d9..6cf497448 100644 --- a/conf/securesocial.conf +++ b/conf/securesocial.conf @@ -162,9 +162,9 @@ securesocial { } keycloak { - authorizationUrl="http://localhost:8080/keycloak/realms/clowder1/protocol/openid-connect/auth" - accessTokenUrl="http://localhost:8080/keycloak/realms/clowder1/protocol/openid-connect/token" - userinfoUrl="http://localhost:8080/keycloak/realms/clowder1/protocol/openid-connect/userinfo" + 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" From dd2c7ae3853e6ad013c64bccd9b77cfc65e8a7e4 Mon Sep 17 00:00:00 2001 From: Chen Wang Date: Mon, 28 Aug 2023 11:27:23 -0500 Subject: [PATCH 07/10] typo --- app/services/KeycloakProvider.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/services/KeycloakProvider.scala b/app/services/KeycloakProvider.scala index 754e572f9..59edf01d5 100644 --- a/app/services/KeycloakProvider.scala +++ b/app/services/KeycloakProvider.scala @@ -49,10 +49,10 @@ class KeycloakProvider(application: Application) extends OAuth2Provider(applicat val avatarUrl = ( me \ Picture).asOpt[String] val email = ( me \ Email).asOpt[String] val groups = ( me \ Groups).asOpt[List[String]] - (application.configuration.getList("securesocial.cilogon.groups"), groups) match { - case (Some(conf), Some(cilogon)) => { + (application.configuration.getList("securesocial.keycloak.groups"), groups) match { + case (Some(conf), Some(keycloak)) => { val conflist = conf.unwrapped().asScala.toList - if (cilogon.intersect(conflist).isEmpty) { + if (keycloak.intersect(conflist).isEmpty) { throw new AuthenticationException() } } From f686690510e86c8051baf4056c5f1ea057c5e929 Mon Sep 17 00:00:00 2001 From: Chen Wang Date: Mon, 28 Aug 2023 11:59:41 -0500 Subject: [PATCH 08/10] use the correct group --- app/services/KeycloakProvider.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/services/KeycloakProvider.scala b/app/services/KeycloakProvider.scala index 59edf01d5..0543cb355 100644 --- a/app/services/KeycloakProvider.scala +++ b/app/services/KeycloakProvider.scala @@ -18,9 +18,10 @@ class KeycloakProvider(application: Application) extends OAuth2Provider(applicat val Name = "name" val GivenName = "given_name" val FamilyName = "family_name" + // todo: picture wont work val Picture = "picture" val Email = "email" - val Groups = "isMemberOf" + val Groups = "groups" override def id = KeycloakProvider.Keycloak From 388496ce3512c856b879d5fb6a6e90995ece42fa Mon Sep 17 00:00:00 2001 From: Chen Wang Date: Fri, 8 Sep 2023 10:18:24 -0500 Subject: [PATCH 09/10] check roles also --- app/services/KeycloakProvider.scala | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/services/KeycloakProvider.scala b/app/services/KeycloakProvider.scala index 0543cb355..dab72cda6 100644 --- a/app/services/KeycloakProvider.scala +++ b/app/services/KeycloakProvider.scala @@ -23,7 +23,6 @@ class KeycloakProvider(application: Application) extends OAuth2Provider(applicat val Email = "email" val Groups = "groups" - override def id = KeycloakProvider.Keycloak def fillProfile(user: SocialUser): SocialUser = { @@ -50,6 +49,7 @@ class KeycloakProvider(application: Application) extends OAuth2Provider(applicat 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 @@ -60,6 +60,16 @@ class KeycloakProvider(application: Application) extends OAuth2Provider(applicat 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(""), From ae950fd4368936ac65dadf4629bbefc46ab2341a Mon Sep 17 00:00:00 2001 From: Chen Wang Date: Fri, 8 Sep 2023 11:01:22 -0500 Subject: [PATCH 10/10] provide examples of filtering by grouops and roles --- conf/securesocial.conf | 3 +++ 1 file changed, 3 insertions(+) diff --git a/conf/securesocial.conf b/conf/securesocial.conf index 6cf497448..afa4fc019 100644 --- a/conf/securesocial.conf +++ b/conf/securesocial.conf @@ -168,6 +168,9 @@ securesocial { 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 {