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}