001/*
002 *  Copyright 2016 Anyware Services
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 */
016package org.ametys.plugins.extrausermgt.authentication.kerberos;
017
018import java.util.Map;
019import java.util.concurrent.Callable;
020import java.util.regex.Pattern;
021
022import javax.security.auth.Subject;
023import javax.security.auth.callback.Callback;
024import javax.security.auth.callback.CallbackHandler;
025import javax.security.auth.callback.NameCallback;
026import javax.security.auth.callback.PasswordCallback;
027import javax.security.auth.login.AppConfigurationEntry;
028import javax.security.auth.login.Configuration;
029import javax.security.auth.login.LoginContext;
030import javax.security.auth.login.LoginException;
031
032import org.apache.avalon.framework.activity.Disposable;
033import org.apache.avalon.framework.context.Context;
034import org.apache.avalon.framework.context.ContextException;
035import org.apache.avalon.framework.context.Contextualizable;
036import org.apache.cocoon.components.ContextHelper;
037import org.apache.cocoon.environment.Redirector;
038import org.apache.cocoon.environment.Request;
039import org.apache.cocoon.environment.Response;
040import org.apache.cocoon.environment.Session;
041import org.apache.commons.codec.binary.Base64;
042import org.apache.commons.lang3.StringUtils;
043import org.ietf.jgss.GSSContext;
044import org.ietf.jgss.GSSCredential;
045import org.ietf.jgss.GSSException;
046import org.ietf.jgss.GSSManager;
047import org.ietf.jgss.GSSName;
048import org.ietf.jgss.Oid;
049
050import org.ametys.core.authentication.AbstractCredentialProvider;
051import org.ametys.core.authentication.NonBlockingCredentialProvider;
052import org.ametys.core.user.UserIdentity;
053import org.ametys.runtime.workspace.WorkspaceMatcher;
054
055/**
056 * Kerberos http authentication.
057 */
058public class KerberosCredentialProvider extends AbstractCredentialProvider implements NonBlockingCredentialProvider, Contextualizable, Disposable
059{
060    /** Name of the parameter holding the authentication server kdc adress */
061    protected static final String __PARAM_KDC = "authentication.kerberos.kdc";
062    /** Name of the parameter holding the authentication server realm */
063    protected static final String __PARAM_REALM = "authentication.kerberos.realm";
064    /** Name of the parameter holding the ametys login */
065    protected static final String __PARAM_LOGIN = "authentication.kerberos.login";
066    /** Name of the parameter holding the ametys password */
067    protected static final String __PARAM_PASSWORD = "authentication.kerberos.password";
068    /** Name of the parameter holding the regexp to match ip adresses */
069    protected static final String __PARAM_IPRESTRICTION = "authentication.kerberos.ip-limitation-regexp";
070    
071    /** Name of the login config file */
072    protected static final String __LOGIN_CONF_FILE = "jaas.conf";
073
074    /** The url to redirect to skip kerberos current authentication */
075    protected static final String __SKIP_KERBEROS_URL = "cocoon://_plugins/extra-user-management/userpopulations/credentialproviders/kerberos";
076    
077    /** Kerberos context */
078    protected static final String __SESSION_ATTRIBUTE_GSSCONTEXT = "GSSContext";
079
080    private Context _context;
081    private GSSCredential _gssCredential;
082    private Pattern _ipRestriction;
083
084    @Override
085    public void contextualize(Context context) throws ContextException
086    {
087        _context = context;
088    }
089    
090    /**
091     * Create a logged in LoginContext for Kerberos
092     * @param realm The realm
093     * @param login The identifier of a user to the kdc
094     * @param password The associated password
095     * @return A non null LoginContext (to be logged out)
096     * @throws LoginException If the login process failed
097     */
098    public static LoginContext createLoginContext(String realm, String login, String password) throws LoginException 
099    {
100        Configuration loginConfig = null;
101        if (System.getProperty("java.security.auth.login.config") == null)
102        {
103            loginConfig = new Configuration() 
104            {
105                @Override
106                public AppConfigurationEntry[] getAppConfigurationEntry(String name) 
107                {
108                    Map<String, String> options = Map.of("storeKey", "true", "isInitiator", "false");
109                    return new AppConfigurationEntry[] {new AppConfigurationEntry("com.sun.security.auth.module.Krb5LoginModule", AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options)};
110                }
111            };
112        }
113        
114        LoginContext loginContext = new LoginContext("kerberos", null, new CallbackHandler()
115        {
116            public void handle(final Callback[] callbacks)
117            {
118                for (Callback callback : callbacks)
119                {
120                    if (callback instanceof NameCallback)
121                    {
122                        ((NameCallback) callback).setName(login + "@" + realm.toUpperCase());
123                    }
124                    else if (callback instanceof PasswordCallback)
125                    {
126                        ((PasswordCallback) callback).setPassword(password.toCharArray());
127                    }
128                    else
129                    {
130                        throw new RuntimeException("Invalid callback received during KerberosCredentialProvider initialization");
131                    }
132                }
133            }
134        }, loginConfig);
135        
136        loginContext.login();
137        
138        return loginContext;
139    }
140    
141    @Override
142    public void init(String id, String cpModelId, Map<String, Object> paramValues, String label) throws Exception
143    {
144        super.init(id, cpModelId, paramValues, label);
145
146        String ipRegexp = (String) paramValues.get(__PARAM_IPRESTRICTION);
147        if (StringUtils.isNotBlank(ipRegexp))
148        {
149            _ipRestriction = Pattern.compile(ipRegexp);
150        }
151        else
152        {
153            _ipRestriction = null;
154        }
155        
156        String realm = (String) paramValues.get(__PARAM_REALM);
157        String login = (String) paramValues.get(__PARAM_LOGIN);
158        String password = (String) paramValues.get(__PARAM_PASSWORD);
159        
160        try
161        {
162            LoginContext loginContext = createLoginContext(realm, login, password);
163            
164            GSSManager manager = GSSManager.getInstance();
165            
166            Callable<GSSCredential> action = new Callable<>() 
167            {
168                public GSSCredential call() throws GSSException 
169                {
170                    return manager.createCredential(null, GSSCredential.INDEFINITE_LIFETIME, new Oid("1.3.6.1.5.5.2"), GSSCredential.ACCEPT_ONLY);
171                } 
172            };
173            
174            _gssCredential = Subject.callAs(loginContext.getSubject(), action);
175        }
176        catch (LoginException e)
177        {
178            throw new RuntimeException("Unable to initialize the KerberosCredentialProvider", e);
179        }
180    }
181    
182    @Override
183    public boolean nonBlockingIsStillConnected(UserIdentity userIdentity, Redirector redirector) throws Exception
184    {
185        // this manager is always valid
186        return true;
187    }
188    
189    @Override
190    public boolean nonBlockingGrantAnonymousRequest()
191    {
192        Request request = ContextHelper.getRequest(_context);
193        
194        // URL without server context and leading slash.
195        String url = (String) request.getAttribute(WorkspaceMatcher.IN_WORKSPACE_URL);
196        
197        return "plugins/extra-user-management/userpopulations/credentialproviders/kerberos/skip".equals(url);
198    }
199
200    @Override
201    public UserIdentity nonBlockingGetUserIdentity(Redirector redirector) throws Exception
202    {
203        Request request = ContextHelper.getRequest(_context);
204        Response response = ContextHelper.getResponse(_context);
205        
206        if (!_isIPAuthorized(request))
207        {
208            return null;
209        }
210        
211        String authorization = request.getHeader("Authorization");
212        if (authorization != null && authorization.startsWith("Negotiate "))
213        {
214            String negotiateToken = authorization.substring("Negotiate ".length());
215            
216            if (negotiateToken.startsWith("TlRMTVNT"))
217            {
218                // Oups, this is a NTLM token => the user is not on the domain or missing SPN => give up
219                getLogger().debug("A user tried an NTLM token. Let's ignore it.");
220                return null;
221            }
222            
223            getLogger().debug("Received token {}", negotiateToken);
224            
225            byte[] token = Base64.decodeBase64(negotiateToken);
226            
227            Session session = request.getSession(true);
228            GSSContext gssContext = (GSSContext) session.getAttribute(__SESSION_ATTRIBUTE_GSSCONTEXT);
229            if (gssContext == null)
230            {
231                getLogger().debug("Creating new GSSContext");
232                
233                gssContext = GSSManager.getInstance().createContext(_gssCredential);
234                session.setAttribute(__SESSION_ATTRIBUTE_GSSCONTEXT, gssContext);
235            }
236            else
237            {
238                getLogger().debug("Using existing GSSContext");
239            }
240            
241            byte[] kdcTokenAnswer = null;
242            try
243            {
244                kdcTokenAnswer = gssContext.acceptSecContext(token, 0, token.length);
245            }
246            catch (GSSException e)
247            {
248                _disposeContext(gssContext, session);
249                throw e;
250            }
251            
252            String tokenAnswer = kdcTokenAnswer != null ? Base64.encodeBase64String(kdcTokenAnswer) : null;
253            if (!gssContext.isEstablished())
254            {
255                // Handshake is not over, send new token
256                response.setHeader("WWW-Authenticate", "Negotiate " + tokenAnswer);
257                redirector.redirect(false, __SKIP_KERBEROS_URL);
258                getLogger().debug("Need additionnal token. Sending answer token {}", tokenAnswer);
259                return null;
260            }
261            
262            GSSName gssSrcName = gssContext.getSrcName();
263
264            if (gssSrcName == null)
265            {
266                _disposeContext(gssContext, session);
267
268                getLogger().debug("Reseting communication with client");
269                response.setHeader("WWW-Authenticate", "Negotiate");
270                redirector.redirect(false, __SKIP_KERBEROS_URL);
271                return null;
272            }
273            
274            if (tokenAnswer != null)
275            {
276                getLogger().debug("Sending answer token {}", tokenAnswer);
277                response.setHeader("WWW-Authenticate", "Negotiate " + tokenAnswer);
278            }
279            
280            String login = gssSrcName.toString();
281            // gssSrcName should be <login>@<realm>
282            login = StringUtils.substringBefore(login, "@");
283            
284            _disposeContext(gssContext, session);
285            
286            getLogger().debug("User successfully identified '{}'", login);
287            return new UserIdentity(login, null);
288        }
289        else
290        {
291            response.setHeader("WWW-Authenticate", "Negotiate");
292            redirector.redirect(false, __SKIP_KERBEROS_URL);
293            return null;
294        }
295    }
296    
297    private void _disposeContext(GSSContext gssContext, Session session) throws GSSException
298    {
299        gssContext.dispose();
300        session.removeAttribute(__SESSION_ATTRIBUTE_GSSCONTEXT);
301    }
302
303    private boolean _isIPAuthorized(Request request)
304    {
305        if (_ipRestriction == null)
306        {
307            // There is no restriction
308            getLogger().debug("There is no IP restriction for Kerberos");
309            return true;
310        }
311        
312        // The real client IP may have been put in the non-standard "X-Forwarded-For" request header, in case of reverse proxy
313        String xff = request.getHeader("X-Forwarded-For");
314        String ip = null;
315        
316        if (xff != null)
317        {
318            ip = xff.split(",")[0];
319        }
320        else
321        {
322            ip = request.getRemoteAddr();
323        }
324        
325        if (!_ipRestriction.matcher(ip).matches())
326        {
327            getLogger().info("Ip '" + ip + "' was not authorized to use Kerberos authentication with filter " + _ipRestriction.pattern());
328            return false;
329        }
330        
331        return true;
332    }
333
334    @Override
335    public void nonBlockingUserNotAllowed(Redirector redirector)
336    {
337        Request request = ContextHelper.getRequest(_context);
338        Session session = request.getSession(false);
339
340        if (session != null)
341        {
342            session.removeAttribute(__SESSION_ATTRIBUTE_GSSCONTEXT);
343        }
344    }
345    
346    @Override
347    public void nonBlockingUserAllowed(UserIdentity userIdentity, Redirector redirector)
348    {
349        Request request = ContextHelper.getRequest(_context);
350        Session session = request.getSession(false);
351
352        if (session != null)
353        {
354            session.removeAttribute(__SESSION_ATTRIBUTE_GSSCONTEXT);
355        }
356    }
357    
358    public void dispose()
359    {
360        try
361        {
362            _gssCredential.dispose();
363        }
364        catch (GSSException e)
365        {
366            throw new RuntimeException("Unable to dispose the GSSCredential during KerberosCredentialProvider disposal", e);
367        }
368    }
369}