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}