Blogs
Or how to integrate Liferay with Keycloak
I just completed a project that integrated Liferay 7.0 GA7 with Keycloak 4.8 for both authentication and authorization. For those who are not familar with Keycloak it is an open source access and identity manager, https://www.keycloak.org/.
The authentication piece of this integration was assisted by the use of the use of the OpenId Connect Plugin that is available in the Liferay Marketplace. Authorization was achieved through the use of the a Liferay post login hook and the Keycloak Restful API.
This integration also involved the use of Apache Httpd for the virtual hosting of both Liferay and Keycloak. This virtual hosting provided SSL support as well.
This blog assumes that Liferay, Keycloak and Apache Httpd are installed and that AJP is enabled on both the Liferay and Keycloak application servers. AJP will be used by Httpd virtual hosting to proxy request to both Liferay and Keycloak. The following is the Httpd's settings that were used to perform this virtual hosting:
#virtual host for liferay
<VirtualHost *:443>
ServerName aim1.xxxxxx.net
SSLEngine on
SSLCertificateFile /etc/pki/tls/certs/aim1.xxxxxxx.net.cert.pem
SSLCertificateKeyFile /etc/pki/tls/private/aim1.xxxxxx.net.key.pem
# Set the header for the https protocol
#RequestHeader set X-Forwarded-Proto "https"
#RequestHeader set X-Forwarded-Port "443"
# Serve /excluded from the local httpd data
ProxyPass /excluded !
# Preserve the host when invoking tomcat
ProxyPreserveHost on
# Pass all traffic to a localhost tomcat.
ProxyPass / ajp://localhost:8009/
ProxyPassReverse / ajp://localhost:8009/
</VirtualHost>
# virtual host for keycloak
Listen 17443
<VirtualHost *:17443>
ServerName aim1.aifoundry.net
SSLEngine on
SSLCertificateFile /etc/pki/tls/certs/aim1.xxxxxx.net.cert.pem
SSLCertificateKeyFile /etc/pki/tls/private/aim1.xxxxxx.net.key.pem
# Set the header for the https protocol
#RequestHeader set X-Forwarded-Proto "https"
#RequestHeader set X-Forwarded-Port "443"
# Serve /excluded from the local httpd data
ProxyPass /excluded !
# Preserve the host when invoking tomcat
ProxyPreserveHost on
# Pass all traffic to a localhost tomcat.
ProxyPass / ajp://localhost:8589/
ProxyPassReverse / ajp://localhost:8589/
</VirtualHost>
The settings above assume that Httpd, Liferay and Keycloak are all on the same host. If this not the case then localhost should be substituted with a valid IP address or hostname for the Liferay and Keycloak servers.
Keycloak Configuration
For the Keycloak configuration I created a separate realm for Liferay that had specific client, roles and users. The client configuration is probably the must important part of the realm configuration, especially its redirect URL:
The redirect URL, https://aim1.xxxxxx.net/*, shown above is base URL of the Liferay site I want to authenticate with Keycloak. Note the * wildcard character which allows Keycloak to access everything under this URL, especially /c/portal/login.
Liferay's OpenID Connect PLugin Configuration
One other assumption that I make is that the OpenID Connect Plugin has been downloaded from the Liferay Marketplace and deployed to Liferay. Once that is done an additional tab should appear in the Authentication section of the Liferay Control Panel's Instance Settings. The following are the settings that were used for my instance:
One thing to bear in mind because I'm coming through my Httpd front door using SSL the JWT token that was generated by Keycloak is getting encrypted. In order for the plugin to be able to decrypt it the JVM that Liferay is running needs to have its SSL certificates updated with the certificate the Httpd is using.
With the plugin configured and enabled Liferay can now participate in the SSO being provided by Keycloak. If you get authenticated by Keycloak while accessing another application that it's also protecting you should have a valid JWT token in your browser. This should allow you to access Liferay without having to login. If you're not yet authenticated the plugin will force you to authenticate with Keycloak.
More information on the OpenID Connect Plugin can be found at the following URL: https://github.com/finalist/liferay-oidc-plugin/blob/oidc-parent-0.5.0/README.md
Take note of the great sequence diagram located at the end of this readme page.
What About Authorization
If Keycloak SSO is working correctly we can now log into Liferay with user that are being managed by Keycloak:
The OpenID Connect Plugin will automatically add these authenticated users into Liferay's user repository.
These users can also be assigned roles in Keycloak:
User roles information can be accessed using the Keycloak's Restful API. I found that the most direct way to this with Keycloaks's API is to use the following restful method which allows you to get a role's membership by its name:
GET /{realm}/clients/{id}/roles/{role-name}/users
I employed this call in as part of a restful client that gets both the access token and the user's roles:
public String getAdminAccessToken() {
Form form = new Form();
form.param(USERNAME, AUTH_SERVICE_USERNAME);
form.param(PASSWORD, AUTH_SERVICE_PASSWORD);
form.param(GRANT_TYPE, AUTH_SERVICE_GRANT_TYPE);
form.param(CLIENT_ID, AUTH_SERVICE_CLIENT_ID);
Boolean encode = true;
String accessToken = null;
Response response = client.postForm(AUTH_SERVICE_URL, AUTH_SERVICE_TOKEN_PATH, MediaType.APPLICATION_JSON, form,
encode);
if (response != null) {
AuthToken authToken = response.readEntity(AuthToken.class);
if (authToken != null) {
accessToken = authToken.getAccessToken();
} else {
LOGGER.error(COULD_NOT_GET_ADMIN_AUTH_TOKEN);
}
} else {
LOGGER.error(COULD_NOT_GET_ADMIN_AUTH_RESPONSE);
}
return accessToken;
}
public List<String> getUserRoles(String accessToken, String userName) {
if (accessToken == null || accessToken.isEmpty()) {
LOGGER.error(INVALID_ARGUMENT_NO_ACCESS_TOKEN_PROVIDED);
return null;
}
if (userName == null || userName.isEmpty()) {
LOGGER.error(INVALID_ARGUMENT_NO_USER_NAME_PROVIDED);
return null;
}
List<String> userRoles = new ArrayList<String>();
for (String authServiceRole : AUTH_SERVICE_ROLES) {
String roleUserPath = AUTH_SERVICE_ROLES_PATH + SLASH + authServiceRole + USERS_PATH;
Response response = client.get(AUTH_SERVICE_URL, roleUserPath, MediaType.APPLICATION_JSON, null,
accessToken);
if (response != null) {
List<AuthUser> authUsers = response.readEntity(new GenericType<List<AuthUser>>() {
});
if (authUsers !=null && authUsers.size() > 0) {
for (AuthUser authUser : authUsers) {
String authUserName = authUser.getUsername();
if (authUserName != null && authUserName.equals(userName)) {
userRoles.add(authServiceRole);
break;
}
}
}
}
}
return userRoles;
}
There are other ways to do this but they would require multiple restful calls to achieve the same result. Unfortunately there seems to be no way with the current Keycloak API to access a user's roles directly by using a username at this time. More information of the Keycloak API can be found at the following URL: https://www.keycloak.org/docs-api/4.8/rest-api/index.html#_roles_resource
The user's Keycloak roles can be synchronized with corresponding Liferay roles using the Liferay API. This can be triggered by a post login event. Here's a method that I have which gets invoked once the user has been successfully authenticated
public String getScreenName() {
ExternalContext externalContext = FacesContext.getCurrentInstance().getExternalContext();
HttpServletRequest request = PortalUtil.getHttpServletRequest((PortletRequest) externalContext.getRequest());
HttpServletRequest originalRequest = PortalUtil.getOriginalServletRequest(request);
HttpServletResponse response = PortalUtil
.getHttpServletResponse((PortletResponse) externalContext.getResponse());
try {
User user = PortalUtil.getUser(originalRequest);
screenName = user.getScreenName();
LOGGER.info("screenName=" + screenName);
String reminderQueryQuestion = user.getReminderQueryQuestion();
if (reminderQueryQuestion == null || reminderQueryQuestion.isEmpty()
|| reminderQueryQuestion.equals(OPENID_CONNECT_REMINDER_QUESTION) == false) {
LOGGER.info("Not an openId Connect user");
response.sendRedirect(landingPage);
return screenName;
}
String adminAccessToken = AUTH_CLIENT.getAdminAccessToken();
LOGGER.info("adminAccessToken=" + adminAccessToken);
if (adminAccessToken != null && adminAccessToken.length() > 0) {
long userId = user.getUserId();
if (screenName != null && screenName.length() > 0) {
List<String> userRoles = AUTH_CLIENT.getUserRoles(adminAccessToken, screenName);
LOGGER.info("userRoles=" + userRoles);
if (userRoles != null && userRoles.size() > 0) {
for (String userRole : userRoles) {
Role role = RoleLocalServiceUtil.getRole(user.getCompanyId(), userRole);
long roleId = role.getRoleId();
boolean hasUserRole = RoleLocalServiceUtil.hasUserRole(userId, roleId);
if (hasUserRole) {
LOGGER.info(screenName + HAS_FOLLOWING_ROLE + role.getName());
continue;
}
RoleLocalServiceUtil.addUserRole(userId, roleId);
LOGGER.info(ADDED + screenName + TO_FOLLOWING_ROLE + role.getName());
}
List<String> AUTH_SERVICE_ROLES = AuthClient.getAUTH_SERVICE_ROLES();
List<Role> portalUserRoles = user.getRoles();
for (Role portalUserRole : portalUserRoles) {
String portalRoleName = portalUserRole.getName();
if(AUTH_SERVICE_ROLES.contains(portalRoleName)) {
if(!userRoles.contains(portalRoleName)) {
RoleLocalServiceUtil.deleteUserRole(userId, portalUserRole.getRoleId());
LOGGER.info(DELETED + screenName + FROM_THE_FOLLOWING + portalRoleName);
}
}
}
if (user.getRoleIds().length <= 1) {
throw new Exception(screenName + USER_HAS_NO_ROLES);
}
} else {
throw new Exception(screenName + USER_HAS_NO_ROLES);
}
}
}
response.sendRedirect(landingPage);
} catch (Exception e) {
LOGGER.error(e.getLocalizedMessage());
try {
response.sendRedirect(errorPage);
} catch (IOException e1) {
LOGGER.error(e.getLocalizedMessage());
}
}
return screenName;
}
The code above will both add or remove a user to or from a roles in Liferay , respectively, based on the the corresponding roles that they have in Ke ycloak.
I hope some will find this blog useful and that it saves them some time.