001/*
002 * (C) Copyright 2006-2009 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 *     Nuxeo - initial API and implementation
018 *     Academie de Rennes - proxy CAS support
019 *
020 * $Id: JOOoConvertPluginImpl.java 18651 2007-05-13 20:28:53Z sfermigier $
021 */
022
023package org.nuxeo.ecm.platform.ui.web.auth.cas2;
024
025import java.io.IOException;
026import java.util.ArrayList;
027import java.util.HashMap;
028import java.util.List;
029import java.util.Map;
030
031import javax.servlet.http.Cookie;
032import javax.servlet.http.HttpServletRequest;
033import javax.servlet.http.HttpServletResponse;
034import javax.xml.parsers.ParserConfigurationException;
035
036import org.apache.commons.logging.Log;
037import org.apache.commons.logging.LogFactory;
038import org.nuxeo.common.utils.URIUtils;
039import org.nuxeo.ecm.platform.api.login.UserIdentificationInfo;
040import org.nuxeo.ecm.platform.ui.web.auth.interfaces.LoginResponseHandler;
041import org.nuxeo.ecm.platform.ui.web.auth.interfaces.NuxeoAuthenticationPlugin;
042import org.nuxeo.ecm.platform.ui.web.auth.interfaces.NuxeoAuthenticationPluginLogoutExtension;
043import org.nuxeo.ecm.platform.ui.web.auth.service.PluggableAuthenticationService;
044import org.nuxeo.ecm.platform.web.common.vh.VirtualHostHelper;
045import org.nuxeo.runtime.api.Framework;
046import org.xml.sax.SAXException;
047
048import edu.yale.its.tp.cas.client.ProxyTicketValidator;
049import edu.yale.its.tp.cas.client.ServiceTicketValidator;
050
051/**
052 * @author Thierry Delprat
053 * @author Olivier Adam
054 * @author M.-A. Darche
055 * @author Benjamin Jalon
056 * @author Thierry Martins
057 */
058public class Cas2Authenticator
059        implements NuxeoAuthenticationPlugin, NuxeoAuthenticationPluginLogoutExtension, LoginResponseHandler {
060
061    protected static final String CAS_SERVER_HEADER_KEY = "CasServer";
062
063    protected static final String CAS_SERVER_PATTERN_KEY = "$CASSERVER";
064
065    protected static final String NUXEO_SERVER_PATTERN_KEY = "$NUXEO";
066
067    protected static final String LOGIN_ACTION = "Login";
068
069    protected static final String LOGOUT_ACTION = "Logout";
070
071    protected static final String VALIDATE_ACTION = "Valid";
072
073    protected static final String PROXY_VALIDATE_ACTION = "ProxyValid";
074
075    protected static final Log log = LogFactory.getLog(Cas2Authenticator.class);
076
077    protected static final String EXCLUDE_PROMPT_KEY = "excludePromptURL";
078
079    protected static final String ALTERNATIVE_AUTH_PLUGIN_COOKIE_NAME = "org.nuxeo.auth.plugin.alternative";
080
081    protected String ticketKey = "ticket";
082
083    protected String proxyKey = "proxy";
084
085    protected String appURL = "http://127.0.0.1:8080/nuxeo/";
086
087    protected String serviceLoginURL = "http://127.0.0.1:8080/cas/login";
088
089    protected String serviceValidateURL = "http://127.0.0.1:8080/cas/serviceValidate";
090
091    /**
092     * We tell the CAS server whether we want a plain text (CAS 1.0) or XML (CAS 2.0) response by making the request
093     * either to the '.../validate' or '.../serviceValidate' URL. The older protocol supports only the CAS 1.0
094     * functionality, which is left around as the legacy '.../validate' URL.
095     */
096    protected String proxyValidateURL = "http://127.0.0.1:8080/cas/proxyValidate";
097
098    protected String serviceKey = "service";
099
100    protected String logoutURL = "";
101
102    protected String defaultCasServer = "";
103
104    protected String ticketValidatorClassName = "edu.yale.its.tp.cas.client.ServiceTicketValidator";
105
106    protected String proxyValidatorClassName = "edu.yale.its.tp.cas.client.ProxyTicketValidator";
107
108    protected boolean promptLogin = true;
109
110    protected List<String> excludePromptURLs;
111
112    protected String errorPage;
113
114    @Override
115    public List<String> getUnAuthenticatedURLPrefix() {
116        // CAS login screen is not part of Nuxeo5 Web App
117        return null;
118    }
119
120    protected String getServiceURL(HttpServletRequest httpRequest, String action) {
121        String url = "";
122        if (action.equals(LOGIN_ACTION)) {
123            url = serviceLoginURL;
124        } else if (action.equals(LOGOUT_ACTION)) {
125            url = logoutURL;
126        } else if (action.equals(VALIDATE_ACTION)) {
127            url = serviceValidateURL;
128        } else if (action.equals(PROXY_VALIDATE_ACTION)) {
129            url = proxyValidateURL;
130        }
131
132        if (url.contains(CAS_SERVER_PATTERN_KEY)) {
133            String serverURL = httpRequest.getHeader(CAS_SERVER_HEADER_KEY);
134            if (serverURL != null) {
135                url = url.replace(CAS_SERVER_PATTERN_KEY, serverURL);
136            } else {
137                if (url.contains(CAS_SERVER_PATTERN_KEY)) {
138                    url = url.replace(CAS_SERVER_PATTERN_KEY, defaultCasServer);
139                }
140            }
141        }
142        log.debug("serviceUrl: " + url);
143        return url;
144    }
145
146    @Override
147    public Boolean handleLoginPrompt(HttpServletRequest httpRequest, HttpServletResponse httpResponse, String baseURL) {
148
149        // Check for an alternative authentication plugin in request cookies
150        NuxeoAuthenticationPlugin alternativeAuthPlugin = getAlternativeAuthPlugin(httpRequest, httpResponse);
151        if (alternativeAuthPlugin != null) {
152            log.debug(String.format("Found alternative authentication plugin %s, using it to handle login prompt.",
153                    alternativeAuthPlugin));
154            return alternativeAuthPlugin.handleLoginPrompt(httpRequest, httpResponse, baseURL);
155        }
156
157        // Redirect to CAS Login screen
158        // passing our application URL as service name
159        String location = null;
160        try {
161            Map<String, String> urlParameters = new HashMap<>();
162            urlParameters.put("service", getAppURL(httpRequest));
163            location = URIUtils.addParametersToURIQuery(getServiceURL(httpRequest, LOGIN_ACTION), urlParameters);
164            httpResponse.sendRedirect(location);
165        } catch (IOException e) {
166            log.error("Unable to redirect to CAS login screen to " + location, e);
167            return false;
168        }
169        return true;
170    }
171
172    protected String getAppURL(HttpServletRequest httpRequest) {
173        if (isValidStartupPage(httpRequest)) {
174            StringBuilder sb = new StringBuilder(VirtualHostHelper.getServerURL(httpRequest));
175            if (VirtualHostHelper.getServerURL(httpRequest).endsWith("/")) {
176                sb.deleteCharAt(sb.length() - 1);
177            }
178            sb.append(httpRequest.getRequestURI());
179            if (httpRequest.getQueryString() != null) {
180                sb.append("?");
181                sb.append(httpRequest.getQueryString());
182
183                // remove ticket parameter from URL to correctly validate the
184                // service
185                int indexTicketKey = sb.lastIndexOf(ticketKey + "=");
186                if (indexTicketKey != -1) {
187                    sb.delete(indexTicketKey - 1, sb.length());
188                }
189            }
190
191            return sb.toString();
192        }
193        if (appURL == null || appURL.equals("")) {
194            appURL = NUXEO_SERVER_PATTERN_KEY;
195        }
196        if (appURL.contains(NUXEO_SERVER_PATTERN_KEY)) {
197            String nxurl = VirtualHostHelper.getBaseURL(httpRequest);
198            return appURL.replace(NUXEO_SERVER_PATTERN_KEY, nxurl);
199        } else {
200            return appURL;
201        }
202    }
203
204    private boolean isValidStartupPage(HttpServletRequest httpRequest) {
205        if (httpRequest.getRequestURI() == null) {
206            return false;
207        }
208        PluggableAuthenticationService service = Framework.getService(PluggableAuthenticationService.class);
209        if (service == null) {
210            return false;
211        }
212        String startPage = httpRequest.getRequestURI().replace(VirtualHostHelper.getContextPath(httpRequest) + "/", "");
213        for (String prefix : service.getStartURLPatterns()) {
214            if (startPage.startsWith(prefix)) {
215                return true;
216            }
217        }
218        return false;
219    }
220
221    @Override
222    public UserIdentificationInfo handleRetrieveIdentity(HttpServletRequest httpRequest,
223            HttpServletResponse httpResponse) {
224        String casTicket = httpRequest.getParameter(ticketKey);
225
226        // Retrieve the proxy parameter for knowing if the caller is à proxy
227        // CAS
228        String proxy = httpRequest.getParameter(proxyKey);
229
230        if (casTicket == null) {
231            log.debug("No ticket found");
232            return null;
233        }
234
235        String userName;
236
237        if (proxy == null) {
238            // no ticket found
239            userName = checkCasTicket(casTicket, httpRequest);
240        } else {
241            userName = checkProxyCasTicket(casTicket, httpRequest);
242        }
243
244        if (userName == null) {
245            return null;
246        }
247
248        UserIdentificationInfo uui = new UserIdentificationInfo(userName);
249        uui.setToken(casTicket);
250
251        return uui;
252    }
253
254    @Override
255    public void initPlugin(Map<String, String> parameters) {
256        if (parameters.containsKey(CAS2Parameters.TICKET_NAME_KEY)) {
257            ticketKey = parameters.get(CAS2Parameters.TICKET_NAME_KEY);
258        }
259        if (parameters.containsKey(CAS2Parameters.PROXY_NAME_KEY)) {
260            proxyKey = parameters.get(CAS2Parameters.PROXY_NAME_KEY);
261        }
262        if (parameters.containsKey(CAS2Parameters.NUXEO_APP_URL_KEY)) {
263            appURL = parameters.get(CAS2Parameters.NUXEO_APP_URL_KEY);
264        }
265        if (parameters.containsKey(CAS2Parameters.SERVICE_LOGIN_URL_KEY)) {
266            serviceLoginURL = parameters.get(CAS2Parameters.SERVICE_LOGIN_URL_KEY);
267        }
268        if (parameters.containsKey(CAS2Parameters.SERVICE_VALIDATE_URL_KEY)) {
269            serviceValidateURL = parameters.get(CAS2Parameters.SERVICE_VALIDATE_URL_KEY);
270        }
271        if (parameters.containsKey(CAS2Parameters.PROXY_VALIDATE_URL_KEY)) {
272            proxyValidateURL = parameters.get(CAS2Parameters.PROXY_VALIDATE_URL_KEY);
273        }
274        if (parameters.containsKey(CAS2Parameters.SERVICE_NAME_KEY)) {
275            serviceKey = parameters.get(CAS2Parameters.SERVICE_NAME_KEY);
276        }
277        if (parameters.containsKey(CAS2Parameters.LOGOUT_URL_KEY)) {
278            logoutURL = parameters.get(CAS2Parameters.LOGOUT_URL_KEY);
279        }
280        if (parameters.containsKey(CAS2Parameters.DEFAULT_CAS_SERVER_KEY)) {
281            defaultCasServer = parameters.get(CAS2Parameters.DEFAULT_CAS_SERVER_KEY);
282        }
283        if (parameters.containsKey(CAS2Parameters.SERVICE_VALIDATOR_CLASS)) {
284            ticketValidatorClassName = parameters.get(CAS2Parameters.SERVICE_VALIDATOR_CLASS);
285        }
286        if (parameters.containsKey(CAS2Parameters.PROXY_VALIDATOR_CLASS)) {
287            proxyValidatorClassName = parameters.get(CAS2Parameters.PROXY_VALIDATOR_CLASS);
288        }
289        if (parameters.containsKey(CAS2Parameters.PROMPT_LOGIN)) {
290            promptLogin = Boolean.parseBoolean(parameters.get(CAS2Parameters.PROMPT_LOGIN));
291        }
292        excludePromptURLs = new ArrayList<>();
293        for (String key : parameters.keySet()) {
294            if (key.startsWith(EXCLUDE_PROMPT_KEY)) {
295                excludePromptURLs.add(parameters.get(key));
296            }
297        }
298        if (parameters.containsKey(CAS2Parameters.ERROR_PAGE)) {
299            errorPage = parameters.get(CAS2Parameters.ERROR_PAGE);
300        }
301    }
302
303    @Override
304    public Boolean needLoginPrompt(HttpServletRequest httpRequest) {
305        String requestedURI = httpRequest.getRequestURI();
306        String context = httpRequest.getContextPath() + '/';
307        requestedURI = requestedURI.substring(context.length());
308        for (String prefixURL : excludePromptURLs) {
309            if (requestedURI.startsWith(prefixURL)) {
310                return false;
311            }
312        }
313        return promptLogin;
314    }
315
316    @Override
317    public Boolean handleLogout(HttpServletRequest httpRequest, HttpServletResponse httpResponse) {
318
319        // Check for an alternative authentication plugin in request cookies
320        NuxeoAuthenticationPlugin alternativeAuthPlugin = getAlternativeAuthPlugin(httpRequest, httpResponse);
321        if (alternativeAuthPlugin != null) {
322            if (alternativeAuthPlugin instanceof NuxeoAuthenticationPluginLogoutExtension) {
323                log.debug(String.format("Found alternative authentication plugin %s, using it to handle logout.",
324                        alternativeAuthPlugin));
325                return ((NuxeoAuthenticationPluginLogoutExtension) alternativeAuthPlugin).handleLogout(httpRequest,
326                        httpResponse);
327            } else {
328                log.debug(String.format(
329                        "Found alternative authentication plugin %s which cannot handle logout, letting authentication filter handle it.",
330                        alternativeAuthPlugin));
331                return false;
332            }
333        }
334
335        if (logoutURL == null || logoutURL.equals("")) {
336            log.debug("No CAS logout params, skipping CAS2Logout");
337            return false;
338        }
339        try {
340            httpResponse.sendRedirect(getServiceURL(httpRequest, LOGOUT_ACTION));
341        } catch (IOException e) {
342            log.error("Unable to redirect to CAS logout screen:", e);
343            return false;
344        }
345        return true;
346    }
347
348    protected String checkProxyCasTicket(String ticket, HttpServletRequest httpRequest) {
349        // Get the service passed by the portlet
350        String service = httpRequest.getParameter(serviceKey);
351        if (service == null) {
352            log.error("checkProxyCasTicket: no service name in the URL");
353            return null;
354        }
355
356        ProxyTicketValidator proxyValidator;
357        try {
358            proxyValidator = (ProxyTicketValidator) Framework.getRuntime()
359                                                             .getContext()
360                                                             .loadClass(proxyValidatorClassName)
361                                                             .getDeclaredConstructor()
362                                                             .newInstance();
363        } catch (ReflectiveOperationException e) {
364            log.error("checkProxyCasTicket during the ProxyTicketValidator initialization", e);
365            return null;
366        }
367
368        proxyValidator.setCasValidateUrl(getServiceURL(httpRequest, PROXY_VALIDATE_ACTION));
369        proxyValidator.setService(service);
370        proxyValidator.setServiceTicket(ticket);
371        try {
372            proxyValidator.validate();
373        } catch (IOException e) {
374            log.error("checkProxyCasTicket failed with IOException:", e);
375            return null;
376        } catch (SAXException e) {
377            log.error("checkProxyCasTicket failed with SAXException:", e);
378            return null;
379        } catch (ParserConfigurationException e) {
380            log.error("checkProxyCasTicket failed with ParserConfigurationException:", e);
381            return null;
382        }
383        log.debug("checkProxyCasTicket: validation executed without error");
384        String username = proxyValidator.getUser();
385        log.debug("checkProxyCasTicket: validation returned username = " + username);
386
387        return username;
388    }
389
390    // Cas2 Ticket management
391    protected String checkCasTicket(String ticket, HttpServletRequest httpRequest) {
392        ServiceTicketValidator ticketValidator;
393        try {
394            ticketValidator = (ServiceTicketValidator) Framework.getRuntime()
395                                                                .getContext()
396                                                                .loadClass(ticketValidatorClassName)
397                                                                .getDeclaredConstructor()
398                                                                .newInstance();
399        } catch (ReflectiveOperationException e) {
400            log.error("checkCasTicket during the ServiceTicketValidator initialization", e);
401            return null;
402        }
403
404        ticketValidator.setCasValidateUrl(getServiceURL(httpRequest, VALIDATE_ACTION));
405        ticketValidator.setService(getAppURL(httpRequest));
406        ticketValidator.setServiceTicket(ticket);
407        try {
408            ticketValidator.validate();
409        } catch (IOException e) {
410            log.error("checkCasTicket failed with IOException:", e);
411            return null;
412        } catch (SAXException e) {
413            log.error("checkCasTicket failed with SAXException:", e);
414            return null;
415        } catch (ParserConfigurationException e) {
416            log.error("checkCasTicket failed with ParserConfigurationException:", e);
417            return null;
418        }
419        log.debug("checkCasTicket : validation executed without error");
420        String username = ticketValidator.getUser();
421        log.debug("checkCasTicket: validation returned username = " + username);
422        return username;
423    }
424
425    @Override
426    public boolean onError(HttpServletRequest request, HttpServletResponse response) {
427        try {
428            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
429            if (errorPage != null) {
430                response.sendRedirect(errorPage);
431            }
432        } catch (IOException e) {
433            log.error(e);
434            return false;
435        }
436        return true;
437    }
438
439    @Override
440    public boolean onSuccess(HttpServletRequest arg0, HttpServletResponse arg1) {
441        // TODO Auto-generated method stub
442        return false;
443    }
444
445    protected NuxeoAuthenticationPlugin getAlternativeAuthPlugin(HttpServletRequest httpRequest,
446            HttpServletResponse httpResponse) {
447
448        Cookie alternativeAuthPluginCookie = getCookie(httpRequest, ALTERNATIVE_AUTH_PLUGIN_COOKIE_NAME);
449        if (alternativeAuthPluginCookie != null) {
450            String alternativeAuthPluginName = alternativeAuthPluginCookie.getValue();
451            PluggableAuthenticationService authService = Framework.getService(PluggableAuthenticationService.class);
452            NuxeoAuthenticationPlugin alternativeAuthPlugin = authService.getPlugin(alternativeAuthPluginName);
453            if (alternativeAuthPlugin == null) {
454                log.error(String.format("No alternative authentication plugin named %s, will remove cookie %s.",
455                        alternativeAuthPluginName, ALTERNATIVE_AUTH_PLUGIN_COOKIE_NAME));
456                removeCookie(httpRequest, httpResponse, alternativeAuthPluginCookie);
457            } else {
458                return alternativeAuthPlugin;
459            }
460        }
461        return null;
462    }
463
464    protected Cookie getCookie(HttpServletRequest httpRequest, String cookieName) {
465        Cookie cookies[] = httpRequest.getCookies();
466        if (cookies != null) {
467            for (int i = 0; i < cookies.length; i++) {
468                if (cookieName.equals(cookies[i].getName())) {
469                    return cookies[i];
470                }
471            }
472        }
473        return null;
474    }
475
476    protected void removeCookie(HttpServletRequest httpRequest, HttpServletResponse httpResponse, Cookie cookie) {
477        log.debug(String.format("Removing cookie %s.", cookie.getName()));
478        cookie.setMaxAge(0);
479        cookie.setValue("");
480        cookie.setPath(httpRequest.getContextPath());
481        httpResponse.addCookie(cookie);
482    }
483}