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