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