001/*
002 * (C) Copyright 2012 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 *     Sylvain Chambon
018 */
019package org.nuxeo.ecm.platform.ui.web.auth.krb5;
020
021import java.io.IOException;
022import java.security.PrivilegedActionException;
023import java.security.PrivilegedExceptionAction;
024import java.util.List;
025import java.util.Map;
026
027import javax.security.auth.Subject;
028import javax.security.auth.login.LoginContext;
029import javax.security.auth.login.LoginException;
030import javax.servlet.http.HttpServletRequest;
031import javax.servlet.http.HttpServletResponse;
032
033import org.apache.commons.codec.binary.Base64;
034import org.apache.commons.logging.Log;
035import org.apache.commons.logging.LogFactory;
036import org.ietf.jgss.GSSContext;
037import org.ietf.jgss.GSSCredential;
038import org.ietf.jgss.GSSException;
039import org.ietf.jgss.GSSManager;
040import org.ietf.jgss.Oid;
041import org.nuxeo.ecm.platform.api.login.UserIdentificationInfo;
042import org.nuxeo.ecm.platform.ui.web.auth.interfaces.NuxeoAuthenticationPlugin;
043import org.nuxeo.ecm.platform.web.common.vh.VirtualHostHelper;
044
045/**
046 * Kerberos v5 in SPNEGO authentication. TODO handle NTLMSSP as a fallback position.
047 *
048 * @author schambon
049 */
050public class Krb5Authenticator implements NuxeoAuthenticationPlugin {
051
052    private static final String CONTEXT_ATTRIBUTE = "Krb5Authenticator_context";
053
054    private static final Log logger = LogFactory.getLog(Krb5Authenticator.class);
055
056    private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
057
058    private static final String AUTHORIZATION = "Authorization";
059
060    private static final String NEGOTIATE = "Negotiate";
061
062    private static final String SKIP_KERBEROS = "X-Skip-Kerberos"; // magic header used by the reverse proxy to skip
063                                                                   // this authenticator
064
065    private static final GSSManager MANAGER = GSSManager.getInstance();
066
067    private GSSCredential serverCredential = null;
068
069    private boolean disabled = false;
070
071    @Override
072    public List<String> getUnAuthenticatedURLPrefix() {
073        return null;
074    }
075
076    @Override
077    public Boolean handleLoginPrompt(HttpServletRequest req, HttpServletResponse res, String baseURL) {
078
079        logger.debug("Sending login prompt...");
080        if (res.getHeader(WWW_AUTHENTICATE) == null) {
081            res.setHeader(WWW_AUTHENTICATE, NEGOTIATE);
082        }
083        // hack to support fallback to form auth in case the
084        // client does not answer the SPNEGO challenge.
085        // This will obviously break if form auth is disabled; but this isn't
086        // much of an issue since other sso filters will not work nicely after
087        // this one (as this one takes over the response and flushes it to start
088        // negotiation).
089        String refresh = String.format("1;url=/%s/login.jsp", VirtualHostHelper.getWebAppName(req));
090        res.setHeader("Refresh", refresh);
091        res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
092        res.setContentLength(0);
093        try {
094            res.flushBuffer();
095
096        } catch (IOException e) {
097            logger.warn("Cannot flush response", e);
098        }
099        return true;
100    }
101
102    @Override
103    public UserIdentificationInfo handleRetrieveIdentity(HttpServletRequest req, HttpServletResponse res) {
104        String authorization = req.getHeader(AUTHORIZATION);
105        if (authorization == null) {
106            return null; // no auth
107        }
108
109        if (!authorization.startsWith(NEGOTIATE)) {
110            logger.warn(
111                    "Received invalid Authorization header (expected: Negotiate then SPNEGO blob): " + authorization);
112            // ignore invalid authorization headers.
113            return null;
114        }
115
116        byte[] token = Base64.decodeBase64(authorization.substring(NEGOTIATE.length() + 1));
117        byte[] respToken;
118
119        GSSContext context;
120
121        try {
122            synchronized (this) {
123                context = (GSSContext) req.getSession().getAttribute(CONTEXT_ATTRIBUTE);
124                if (context == null) {
125                    context = MANAGER.createContext(serverCredential);
126                }
127                respToken = context.acceptSecContext(token, 0, token.length);
128
129            }
130            if (context.isEstablished()) {
131                String principal = context.getSrcName().toString();
132                String username = principal.split("@")[0]; // throw away the realm
133                UserIdentificationInfo info = new UserIdentificationInfo(username);
134                req.getSession().removeAttribute(CONTEXT_ATTRIBUTE);
135                return info;
136            } else {
137                // save context in the HTTP session to be reused after client response
138                req.getSession().setAttribute(CONTEXT_ATTRIBUTE, context);
139                // need another roundtrip
140                res.setHeader(WWW_AUTHENTICATE, NEGOTIATE + " " + Base64.encodeBase64String(respToken));
141                return null;
142            }
143
144        } catch (GSSException ge) {
145            req.getSession().removeAttribute(CONTEXT_ATTRIBUTE);
146            logger.error("Cannot accept provided security token", ge);
147            return null;
148        }
149
150    }
151
152    @Override
153    public void initPlugin(Map<String, String> parameters) {
154
155        try {
156            LoginContext loginContext = new LoginContext("Nuxeo");
157            // note: we assume that all configuration is done in loginconfig, so there are NO parameters here
158            loginContext.login();
159            serverCredential = Subject.doAs(loginContext.getSubject(), getServerCredential);
160            logger.debug("Successfully initialized Kerberos auth module");
161        } catch (LoginException le) {
162            logger.warn("Cannot create LoginContext, disabling Kerberos module", le);
163            this.disabled = true;
164        } catch (PrivilegedActionException pae) {
165            logger.warn("Cannot get server credentials, disabling Kerberos module", pae);
166            this.disabled = true;
167        }
168
169    }
170
171    @Override
172    public Boolean needLoginPrompt(HttpServletRequest req) {
173        return !disabled && (req.getHeader(SKIP_KERBEROS) == null);
174    }
175
176    private PrivilegedExceptionAction<GSSCredential> getServerCredential = () -> MANAGER.createCredential(null,
177            GSSCredential.DEFAULT_LIFETIME, new Oid[] { new Oid("1.3.6.1.5.5.2") /* Oid for Kerberos */,
178                    new Oid("1.2.840.113554.1.2.2") /* Oid for SPNEGO */ },
179            GSSCredential.ACCEPT_ONLY);
180}