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