001/* 002 * (C) Copyright 2015 Nuxeo SA (http://nuxeo.com/) and contributors. 003 * 004 * All rights reserved. This program and the accompanying materials 005 * are made available under the terms of the GNU Lesser General Public License 006 * (LGPL) version 2.1 which accompanies this distribution, and is available at 007 * http://www.gnu.org/licenses/lgpl-2.1.html 008 * 009 * This library is distributed in the hope that it will be useful, 010 * but WITHOUT ANY WARRANTY; without even the implied warranty of 011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 012 * Lesser General Public License for more details. 013 * 014 * Contributors: 015 * François Maturel 016 */ 017package org.nuxeo.ecm.platform.ui.web.keycloak; 018 019import org.keycloak.adapters.AdapterDeploymentContext; 020import org.keycloak.adapters.AuthOutcome; 021import org.keycloak.adapters.KeycloakDeployment; 022import org.keycloak.representations.AccessToken; 023import org.nuxeo.ecm.platform.api.login.UserIdentificationInfo; 024import org.nuxeo.ecm.platform.ui.web.auth.interfaces.NuxeoAuthenticationPlugin; 025import org.nuxeo.ecm.platform.ui.web.auth.interfaces.NuxeoAuthenticationPluginLogoutExtension; 026import org.nuxeo.runtime.api.Framework; 027import org.nuxeo.usermapper.service.UserMapperService; 028import org.slf4j.Logger; 029import org.slf4j.LoggerFactory; 030 031import javax.servlet.http.HttpServletRequest; 032import javax.servlet.http.HttpServletResponse; 033 034import java.io.FileInputStream; 035import java.io.FileNotFoundException; 036import java.io.IOException; 037import java.io.InputStream; 038import java.util.*; 039 040import static org.nuxeo.ecm.platform.ui.web.keycloak.KeycloakUserInfo.KeycloakUserInfoBuilder.aKeycloakUserInfo; 041 042/** 043 * Authentication plugin for handling auth flow with Keyloack 044 * 045 * @since 7.4 046 */ 047 048public class KeycloakAuthenticationPlugin implements NuxeoAuthenticationPlugin, 049 NuxeoAuthenticationPluginLogoutExtension { 050 051 private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAuthenticationPlugin.class); 052 053 private static final String PROTOCOL_CLASSPATH = "classpath:"; 054 055 public static final String KEYCLOAK_CONFIG_FILE_KEY = "keycloakConfigFilename"; 056 057 public static final String KEYCLOAK_MAPPING_NAME_KEY = "mappingName"; 058 059 public static final String DEFAULT_MAPPING_NAME = "keycloak"; 060 061 private String keycloakConfigFile = PROTOCOL_CLASSPATH + "keycloak.json"; 062 063 private KeycloakAuthenticatorProvider keycloakAuthenticatorProvider; 064 065 protected String mappingName = DEFAULT_MAPPING_NAME; 066 067 @Override 068 public void initPlugin(Map<String, String> parameters) { 069 LOGGER.info("INITIALIZE KEYCLOAK"); 070 071 if (parameters.containsKey(KEYCLOAK_CONFIG_FILE_KEY)) { 072 keycloakConfigFile = PROTOCOL_CLASSPATH + parameters.get(KEYCLOAK_CONFIG_FILE_KEY); 073 } 074 075 if (parameters.containsKey(KEYCLOAK_MAPPING_NAME_KEY)) { 076 mappingName = parameters.get(KEYCLOAK_MAPPING_NAME_KEY); 077 } 078 079 InputStream is = loadKeycloakConfigFile(); 080 KeycloakDeployment kd = KeycloakNuxeoDeployment.build(is); 081 keycloakAuthenticatorProvider = new KeycloakAuthenticatorProvider(new AdapterDeploymentContext(kd)); 082 LOGGER.info("Keycloak is using a per-deployment configuration loaded from: " + keycloakConfigFile); 083 } 084 085 @Override 086 public Boolean needLoginPrompt(HttpServletRequest httpRequest) { 087 return Boolean.TRUE; 088 } 089 090 @Override 091 public Boolean handleLoginPrompt(HttpServletRequest httpRequest, HttpServletResponse httpResponse, String baseURL) { 092 return Boolean.TRUE; 093 } 094 095 @Override 096 public List<String> getUnAuthenticatedURLPrefix() { 097 // There are no unauthenticated URLs associated to login prompt. 098 // If user is not authenticated, this plugin will have to redirect user to the keycloak sso login prompt 099 return null; 100 } 101 102 @Override 103 public UserIdentificationInfo handleRetrieveIdentity(HttpServletRequest httpRequest, 104 HttpServletResponse httpResponse) { 105 LOGGER.debug("KEYCLOAK will handle identification"); 106 107 KeycloakRequestAuthenticator authenticator = keycloakAuthenticatorProvider.provide(httpRequest, httpResponse); 108 KeycloakDeployment deployment = keycloakAuthenticatorProvider.getResolvedDeployment(); 109 String keycloakNuxeoApp = deployment.getResourceName(); 110 111 AuthOutcome outcome = authenticator.authenticate(); 112 113 if (outcome == AuthOutcome.AUTHENTICATED) { 114 AccessToken token = (AccessToken) httpRequest.getAttribute(KeycloakRequestAuthenticator.KEYCLOAK_ACCESS_TOKEN); 115 116 KeycloakUserInfo keycloakUserInfo = getKeycloakUserInfo(token); 117 118 UserMapperService ums = Framework.getService(UserMapperService.class); 119 120 keycloakUserInfo.setRoles(getRoles(token, keycloakNuxeoApp)); 121 122 ums.getOrCreateAndUpdateNuxeoPrincipal(mappingName, keycloakUserInfo); 123 124 return keycloakUserInfo; 125 } 126 return null; 127 } 128 129 @Override 130 public Boolean handleLogout(HttpServletRequest httpRequest, HttpServletResponse httpResponse) { 131 LOGGER.debug("KEYCLOAK will handle logout"); 132 133 String uri = keycloakAuthenticatorProvider.logout(httpRequest, httpResponse); 134 try { 135 httpResponse.sendRedirect(uri); 136 } catch (IOException e) { 137 String message = "Could note handle logout with URI: " + uri; 138 LOGGER.error(message); 139 throw new RuntimeException(message); 140 } 141 return Boolean.TRUE; 142 } 143 144 /** 145 * Get keycloak user's information from authentication token 146 * 147 * @param token the keycoak authentication token 148 * @return keycloak user's information 149 */ 150 private KeycloakUserInfo getKeycloakUserInfo(AccessToken token) { 151 return aKeycloakUserInfo() 152 // Required 153 .withUserName(token.getEmail()) 154 // Optional 155 .withFirstName(token.getGivenName()).withLastName(token.getFamilyName()).withCompany( 156 token.getPreferredUsername()).withAuthPluginName("KEYCLOAK_AUTH") 157 // The password is randomly generated has we won't use it 158 .withPassword(UUID.randomUUID().toString()).build(); 159 } 160 161 /** 162 * Get keycloak user's roles from authentication token 163 * 164 * @param token the keycoak authentication token 165 * @param keycloakNuxeoApp the keycoak resource name 166 * @return keycloak user's roles 167 */ 168 private Set<String> getRoles(AccessToken token, String keycloakNuxeoApp) { 169 Set<String> allRoles = new HashSet<>(); 170 allRoles.addAll(token.getRealmAccess().getRoles()); 171 AccessToken.Access nuxeoResource = token.getResourceAccess(keycloakNuxeoApp); 172 if (nuxeoResource != null) { 173 Set<String> nuxeoRoles = nuxeoResource.getRoles(); 174 allRoles.addAll(nuxeoRoles); 175 } 176 return allRoles; 177 } 178 179 /** 180 * Loads Keycloak from configuration file 181 * 182 * @return the configuration file as an {@link InputStream} 183 */ 184 private InputStream loadKeycloakConfigFile() { 185 186 if (keycloakConfigFile.startsWith(PROTOCOL_CLASSPATH)) { 187 String classPathLocation = keycloakConfigFile.replace(PROTOCOL_CLASSPATH, ""); 188 189 LOGGER.debug("Loading config from classpath on location: " + classPathLocation); 190 191 // Try current class classloader first 192 InputStream is = getClass().getClassLoader().getResourceAsStream(classPathLocation); 193 if (is == null) { 194 is = Thread.currentThread().getContextClassLoader().getResourceAsStream(classPathLocation); 195 } 196 197 if (is != null) { 198 return is; 199 } else { 200 String message = "Unable to find config from classpath: " + keycloakConfigFile; 201 LOGGER.error(message); 202 throw new RuntimeException(message); 203 } 204 } else { 205 // Fallback to file 206 try { 207 LOGGER.debug("Loading config from file: " + keycloakConfigFile); 208 return new FileInputStream(keycloakConfigFile); 209 } catch (FileNotFoundException fnfe) { 210 String message = "Config not found on " + keycloakConfigFile; 211 LOGGER.error(message); 212 throw new RuntimeException(message, fnfe); 213 } 214 } 215 } 216 217 public void setKeycloakAuthenticatorProvider(KeycloakAuthenticatorProvider keycloakAuthenticatorProvider) { 218 this.keycloakAuthenticatorProvider = keycloakAuthenticatorProvider; 219 } 220}