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        InputStream is = loadKeycloakConfigFile();
086        KeycloakDeployment kd = KeycloakNuxeoDeployment.build(is);
087        keycloakAuthenticatorProvider = new KeycloakAuthenticatorProvider(new AdapterDeploymentContext(kd));
088        LOGGER.info("Keycloak is using a per-deployment configuration loaded from: " + keycloakConfigFile);
089    }
090
091    @Override
092    public Boolean needLoginPrompt(HttpServletRequest httpRequest) {
093        return Boolean.TRUE;
094    }
095
096    @Override
097    public Boolean handleLoginPrompt(HttpServletRequest httpRequest, HttpServletResponse httpResponse, String baseURL) {
098        return Boolean.TRUE;
099    }
100
101    @Override
102    public List<String> getUnAuthenticatedURLPrefix() {
103        // There are no unauthenticated URLs associated to login prompt.
104        // If user is not authenticated, this plugin will have to redirect user to the keycloak sso login prompt
105        return null;
106    }
107
108    @Override
109    public UserIdentificationInfo handleRetrieveIdentity(HttpServletRequest httpRequest,
110            HttpServletResponse httpResponse) {
111        LOGGER.debug("KEYCLOAK will handle identification");
112
113        KeycloakRequestAuthenticator authenticator = keycloakAuthenticatorProvider.provide(httpRequest, httpResponse);
114        KeycloakDeployment deployment = keycloakAuthenticatorProvider.getResolvedDeployment();
115        String keycloakNuxeoApp = deployment.getResourceName();
116
117        AuthOutcome outcome = authenticator.authenticate();
118
119        if (outcome == AuthOutcome.AUTHENTICATED) {
120            AccessToken token = (AccessToken) httpRequest.getAttribute(KeycloakRequestAuthenticator.KEYCLOAK_ACCESS_TOKEN);
121
122            KeycloakUserInfo keycloakUserInfo = getKeycloakUserInfo(token);
123
124            UserMapperService ums = Framework.getService(UserMapperService.class);
125
126            keycloakUserInfo.setRoles(getRoles(token, keycloakNuxeoApp));
127
128            ums.getOrCreateAndUpdateNuxeoPrincipal(mappingName, keycloakUserInfo);
129
130            return keycloakUserInfo;
131        }
132        return null;
133    }
134
135    @Override
136    public Boolean handleLogout(HttpServletRequest httpRequest, HttpServletResponse httpResponse) {
137        LOGGER.debug("KEYCLOAK will handle logout");
138
139        String uri = keycloakAuthenticatorProvider.logout(httpRequest, httpResponse);
140        try {
141            httpResponse.sendRedirect(uri);
142        } catch (IOException e) {
143            String message = "Could note handle logout with URI: " + uri;
144            LOGGER.error(message);
145            throw new RuntimeException(message);
146        }
147        return Boolean.TRUE;
148    }
149
150    /**
151     * Get keycloak user's information from authentication token
152     *
153     * @param token the keycoak authentication token
154     * @return keycloak user's information
155     */
156    private KeycloakUserInfo getKeycloakUserInfo(AccessToken token) {
157        return aKeycloakUserInfo()
158        // Required
159        .withUserName(token.getEmail())
160        // Optional
161        .withFirstName(token.getGivenName()).withLastName(token.getFamilyName()).withCompany(
162                token.getPreferredUsername()).withAuthPluginName("KEYCLOAK_AUTH")
163        // The password is randomly generated has we won't use it
164        .withPassword(UUID.randomUUID().toString()).build();
165    }
166
167    /**
168     * Get keycloak user's roles from authentication token
169     *
170     * @param token the keycoak authentication token
171     * @param keycloakNuxeoApp the keycoak resource name
172     * @return keycloak user's roles
173     */
174    private Set<String> getRoles(AccessToken token, String keycloakNuxeoApp) {
175        Set<String> allRoles = new HashSet<>();
176        Set<String> roles = token.getRealmAccess().getRoles();
177        if (roles != null) {
178            allRoles.addAll(roles);
179        }
180        AccessToken.Access nuxeoResource = token.getResourceAccess(keycloakNuxeoApp);
181        if (nuxeoResource != null) {
182            Set<String> nuxeoRoles = nuxeoResource.getRoles();
183            allRoles.addAll(nuxeoRoles);
184        }
185        return allRoles;
186    }
187
188    /**
189     * Loads Keycloak from configuration file
190     *
191     * @return the configuration file as an {@link InputStream}
192     */
193    private InputStream loadKeycloakConfigFile() {
194
195        if (keycloakConfigFile.startsWith(PROTOCOL_CLASSPATH)) {
196            String classPathLocation = keycloakConfigFile.replace(PROTOCOL_CLASSPATH, "");
197
198            LOGGER.debug("Loading config from classpath on location: " + classPathLocation);
199
200            // Try current class classloader first
201            InputStream is = getClass().getClassLoader().getResourceAsStream(classPathLocation);
202            if (is == null) {
203                is = Thread.currentThread().getContextClassLoader().getResourceAsStream(classPathLocation);
204            }
205
206            if (is != null) {
207                return is;
208            } else {
209                String message = "Unable to find config from classpath: " + keycloakConfigFile;
210                LOGGER.error(message);
211                throw new RuntimeException(message);
212            }
213        } else {
214            // Fallback to file
215            try {
216                LOGGER.debug("Loading config from file: " + keycloakConfigFile);
217                return new FileInputStream(keycloakConfigFile);
218            } catch (FileNotFoundException fnfe) {
219                String message = "Config not found on " + keycloakConfigFile;
220                LOGGER.error(message);
221                throw new RuntimeException(message, fnfe);
222            }
223        }
224    }
225
226    public void setKeycloakAuthenticatorProvider(KeycloakAuthenticatorProvider keycloakAuthenticatorProvider) {
227        this.keycloakAuthenticatorProvider = keycloakAuthenticatorProvider;
228    }
229}