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}