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