/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.apache.zookeeper.client; import org.apache.zookeeper.AsyncCallback; import org.apache.zookeeper.ClientCnxn; import org.apache.zookeeper.Login; import org.apache.zookeeper.Watcher.Event.KeeperState; import org.apache.zookeeper.ZooDefs; import org.apache.zookeeper.Environment; import org.apache.zookeeper.data.Stat; import org.apache.zookeeper.proto.GetSASLRequest; import org.apache.zookeeper.proto.SetSASLResponse; import org.apache.zookeeper.server.auth.KerberosName; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.security.Principal; import java.security.PrivilegedActionException; import java.security.PrivilegedExceptionAction; import javax.security.auth.Subject; import javax.security.auth.callback.Callback; import javax.security.auth.callback.CallbackHandler; import javax.security.auth.callback.NameCallback; import javax.security.auth.callback.PasswordCallback; import javax.security.auth.callback.UnsupportedCallbackException; import javax.security.auth.login.AppConfigurationEntry; import javax.security.auth.login.Configuration; import javax.security.auth.login.LoginException; import javax.security.sasl.AuthorizeCallback; import javax.security.sasl.RealmCallback; import javax.security.sasl.Sasl; import javax.security.sasl.SaslClient; import javax.security.sasl.SaslException; /** * This class manages SASL authentication for the client. It * allows ClientCnxn to authenticate using SASL with a Zookeeper server. */ public class ZooKeeperSaslClient { public static final String LOGIN_CONTEXT_NAME_KEY = "zookeeper.sasl.clientconfig"; private static final Logger LOG = LoggerFactory.getLogger(ZooKeeperSaslClient.class); private static Login login = null; private SaslClient saslClient; private byte[] saslToken = new byte[0]; public enum SaslState { INITIAL,INTERMEDIATE,COMPLETE,FAILED } private SaslState saslState = SaslState.INITIAL; private boolean gotLastPacket = false; /** informational message indicating the current configuration status */ private final String configStatus; public SaslState getSaslState() { return saslState; } public String getLoginContext() { if (login != null) return login.getLoginContextName(); return null; } public ZooKeeperSaslClient(final String serverPrincipal) throws LoginException { /** * ZOOKEEPER-1373: allow system property to specify the JAAS * configuration section that the zookeeper client should use. * Default to "Client". */ String clientSection = System.getProperty(ZooKeeperSaslClient.LOGIN_CONTEXT_NAME_KEY, "Client"); // Note that 'Configuration' here refers to javax.security.auth.login.Configuration. AppConfigurationEntry entries[] = null; SecurityException securityException = null; try { entries = Configuration.getConfiguration().getAppConfigurationEntry(clientSection); } catch (SecurityException e) { // handle below: might be harmless if the user doesn't intend to use JAAS authentication. securityException = e; } if (entries != null) { this.configStatus = "Will attempt to SASL-authenticate using Login Context section '" + clientSection + "'"; this.saslClient = createSaslClient(serverPrincipal, clientSection); } else { // Handle situation of clientSection's being null: it might simply because the client does not intend to // use SASL, so not necessarily an error. saslState = SaslState.FAILED; String explicitClientSection = System.getProperty(ZooKeeperSaslClient.LOGIN_CONTEXT_NAME_KEY); if (explicitClientSection != null) { // If the user explicitly overrides the default Login Context, they probably expected SASL to // succeed. But if we got here, SASL failed. if (securityException != null) { throw new LoginException("Zookeeper client cannot authenticate using the " + explicitClientSection + " section of the supplied JAAS configuration: '" + System.getProperty(Environment.JAAS_CONF_KEY) + "' because of a " + "SecurityException: " + securityException); } else { throw new LoginException("Client cannot SASL-authenticate because the specified JAAS configuration " + "section '" + explicitClientSection + "' could not be found."); } } else { // The user did not override the default context. It might be that they just don't intend to use SASL, // so log at INFO, not WARN, since they don't expect any SASL-related information. String msg = "Will not attempt to authenticate using SASL "; if (securityException != null) { msg += "(" + securityException.getLocalizedMessage() + ")"; } else { msg += "(unknown error)"; } this.configStatus = msg; } if (System.getProperty(Environment.JAAS_CONF_KEY) != null) { // Again, the user explicitly set something SASL-related, so they probably expected SASL to succeed. if (securityException != null) { throw new LoginException("Zookeeper client cannot authenticate using the '" + System.getProperty(ZooKeeperSaslClient.LOGIN_CONTEXT_NAME_KEY, "Client") + "' section of the supplied JAAS configuration: '" + System.getProperty(Environment.JAAS_CONF_KEY) + "' because of a " + "SecurityException: " + securityException); } else { throw new LoginException("No JAAS configuration section named '" + System.getProperty(ZooKeeperSaslClient.LOGIN_CONTEXT_NAME_KEY, "Client") + "' was found in specified JAAS configuration file: '" + System.getProperty(Environment.JAAS_CONF_KEY) + "'."); } } } } /** * @return informational message indicating the current configuration status. */ public String getConfigStatus() { return configStatus; } public boolean isComplete() { return (saslState == SaslState.COMPLETE); } public boolean isFailed() { return (saslState == SaslState.FAILED); } public static class ServerSaslResponseCallback implements AsyncCallback.DataCallback { public void processResult(int rc, String path, Object ctx, byte data[], Stat stat) { // processResult() is used by ClientCnxn's sendThread to respond to // data[] contains the Zookeeper Server's SASL token. // ctx is the ZooKeeperSaslClient object. We use this object's respondToServer() method // to reply to the Zookeeper Server's SASL token ZooKeeperSaslClient client = ((ClientCnxn)ctx).zooKeeperSaslClient; if (client == null) { LOG.warn("sasl client was unexpectedly null: cannot respond to Zookeeper server."); return; } byte[] usedata = data; if (data != null) { LOG.debug("ServerSaslResponseCallback(): saslToken server response: (length="+usedata.length+")"); } else { usedata = new byte[0]; LOG.debug("ServerSaslResponseCallback(): using empty data[] as server response (length="+usedata.length+")"); } client.respondToServer(usedata, (ClientCnxn)ctx); } } synchronized private SaslClient createSaslClient(final String servicePrincipal, final String loginContext) throws LoginException { try { if (login == null) { if (LOG.isDebugEnabled()) { LOG.debug("JAAS loginContext is: " + loginContext); } // note that the login object is static: it's shared amongst all zookeeper-related connections. // createSaslClient() must be declared synchronized so that login is initialized only once. login = new Login(loginContext, new ClientCallbackHandler(null)); login.startThreadIfNeeded(); } Subject subject = login.getSubject(); SaslClient saslClient; // Use subject.getPrincipals().isEmpty() as an indication of which SASL mechanism to use: // if empty, use DIGEST-MD5; otherwise, use GSSAPI. if (subject.getPrincipals().isEmpty()) { // no principals: must not be GSSAPI: use DIGEST-MD5 mechanism instead. LOG.info("Client will use DIGEST-MD5 as SASL mechanism."); String[] mechs = {"DIGEST-MD5"}; String username = (String)(subject.getPublicCredentials().toArray()[0]); String password = (String)(subject.getPrivateCredentials().toArray()[0]); // "zk-sasl-md5" is a hard-wired 'domain' parameter shared with zookeeper server code (see ServerCnxnFactory.java) saslClient = Sasl.createSaslClient(mechs, username, "zookeeper", "zk-sasl-md5", null, new ClientCallbackHandler(password)); return saslClient; } else { // GSSAPI. final Object[] principals = subject.getPrincipals().toArray(); // determine client principal from subject. final Principal clientPrincipal = (Principal)principals[0]; final KerberosName clientKerberosName = new KerberosName(clientPrincipal.getName()); // assume that server and client are in the same realm (by default; unless the system property // "zookeeper.server.realm" is set). String serverRealm = System.getProperty("zookeeper.server.realm",clientKerberosName.getRealm()); KerberosName serviceKerberosName = new KerberosName(servicePrincipal+"@"+serverRealm); final String serviceName = serviceKerberosName.getServiceName(); final String serviceHostname = serviceKerberosName.getHostName(); final String clientPrincipalName = clientKerberosName.toString(); try { saslClient = Subject.doAs(subject,new PrivilegedExceptionAction<SaslClient>() { public SaslClient run() throws SaslException { LOG.info("Client will use GSSAPI as SASL mechanism."); String[] mechs = {"GSSAPI"}; LOG.debug("creating sasl client: client="+clientPrincipalName+";service="+serviceName+";serviceHostname="+serviceHostname); SaslClient saslClient = Sasl.createSaslClient(mechs,clientPrincipalName,serviceName,serviceHostname,null,new ClientCallbackHandler(null)); return saslClient; } }); return saslClient; } catch (Exception e) { LOG.error("Error creating SASL client:" + e); e.printStackTrace(); return null; } } } catch (LoginException e) { // We throw LoginExceptions... throw e; } catch (Exception e) { // ..but consume (with a log message) all other types of exceptions. LOG.error("Exception while trying to create SASL client: " + e); return null; } } public void respondToServer(byte[] serverToken, ClientCnxn cnxn) { if (saslClient == null) { LOG.error("saslClient is unexpectedly null. Cannot respond to server's SASL message; ignoring."); return; } if (!(saslClient.isComplete())) { try { saslToken = createSaslToken(serverToken); if (saslToken != null) { sendSaslPacket(saslToken, cnxn); } } catch (SaslException e) { LOG.error("SASL authentication failed using login context '" + this.getLoginContext() + "'."); saslState = SaslState.FAILED; gotLastPacket = true; } } if (saslClient.isComplete()) { // GSSAPI: server sends a final packet after authentication succeeds // or fails. if ((serverToken == null) && (saslClient.getMechanismName() == "GSSAPI")) gotLastPacket = true; // non-GSSAPI: no final packet from server. if (saslClient.getMechanismName() != "GSSAPI") { gotLastPacket = true; } // SASL authentication is completed, successfully or not: // enable the socket's writable flag so that any packets waiting for authentication to complete in // the outgoing queue will be sent to the Zookeeper server. cnxn.enableWrite(); } } private byte[] createSaslToken() throws SaslException { saslState = SaslState.INTERMEDIATE; return createSaslToken(saslToken); } private byte[] createSaslToken(final byte[] saslToken) throws SaslException { if (saslToken == null) { // TODO: introspect about runtime environment (such as jaas.conf) saslState = SaslState.FAILED; throw new SaslException("Error in authenticating with a Zookeeper Quorum member: the quorum member's saslToken is null."); } Subject subject = login.getSubject(); if (subject != null) { synchronized(login) { try { final byte[] retval = Subject.doAs(subject, new PrivilegedExceptionAction<byte[]>() { public byte[] run() throws SaslException { LOG.debug("saslClient.evaluateChallenge(len="+saslToken.length+")"); return saslClient.evaluateChallenge(saslToken); } }); return retval; } catch (PrivilegedActionException e) { String error = "An error: (" + e + ") occurred when evaluating Zookeeper Quorum Member's " + " received SASL token."; // Try to provide hints to use about what went wrong so they can fix their configuration. // TODO: introspect about e: look for GSS information. final String UNKNOWN_SERVER_ERROR_TEXT = "(Mechanism level: Server not found in Kerberos database (7) - UNKNOWN_SERVER)"; if (e.toString().indexOf(UNKNOWN_SERVER_ERROR_TEXT) > -1) { error += " This may be caused by Java's being unable to resolve the Zookeeper Quorum Member's" + " hostname correctly. You may want to try to adding" + " '-Dsun.net.spi.nameservice.provider.1=dns,sun' to your client's JVMFLAGS environment."; } error += " Zookeeper Client will go to AUTH_FAILED state."; LOG.error(error); saslState = SaslState.FAILED; throw new SaslException(error); } } } else { throw new SaslException("Cannot make SASL token without subject defined. " + "For diagnosis, please look for WARNs and ERRORs in your log related to the Login class."); } } private void sendSaslPacket(byte[] saslToken, ClientCnxn cnxn) throws SaslException{ if (LOG.isDebugEnabled()) { LOG.debug("ClientCnxn:sendSaslPacket:length="+saslToken.length); } GetSASLRequest request = new GetSASLRequest(); request.setToken(saslToken); SetSASLResponse response = new SetSASLResponse(); ServerSaslResponseCallback cb = new ServerSaslResponseCallback(); try { cnxn.sendPacket(request,response,cb, ZooDefs.OpCode.sasl); } catch (IOException e) { throw new SaslException("Failed to send SASL packet to server.", e); } } private void sendSaslPacket(ClientCnxn cnxn) throws SaslException { if (LOG.isDebugEnabled()) { LOG.debug("ClientCnxn:sendSaslPacket:length="+saslToken.length); } GetSASLRequest request = new GetSASLRequest(); request.setToken(createSaslToken()); SetSASLResponse response = new SetSASLResponse(); ServerSaslResponseCallback cb = new ServerSaslResponseCallback(); try { cnxn.sendPacket(request,response,cb, ZooDefs.OpCode.sasl); } catch (IOException e) { throw new SaslException("Failed to send SASL packet to server due " + "to IOException:", e); } } // used by ClientCnxn to know whether to emit a SASL-related event: either AuthFailed or SaslAuthenticated, // or none, if not ready yet. Sets saslState to COMPLETE as a side-effect. public KeeperState getKeeperState() { if (saslClient != null) { if (saslState == SaslState.FAILED) { return KeeperState.AuthFailed; } if (saslClient.isComplete()) { if (saslState == SaslState.INTERMEDIATE) { saslState = SaslState.COMPLETE; return KeeperState.SaslAuthenticated; } } } // No event ready to emit yet. return null; } // Initialize the client's communications with the Zookeeper server by sending the server the first // authentication packet. public void initialize(ClientCnxn cnxn) throws SaslException { if (saslClient == null) { saslState = SaslState.FAILED; throw new SaslException("saslClient failed to initialize properly: it's null."); } if (saslState == SaslState.INITIAL) { if (saslClient.hasInitialResponse()) { sendSaslPacket(cnxn); } else { byte[] emptyToken = new byte[0]; sendSaslPacket(emptyToken, cnxn); } saslState = SaslState.INTERMEDIATE; } } // The CallbackHandler interface here refers to // javax.security.auth.callback.CallbackHandler. // It should not be confused with Zookeeper packet callbacks like // org.apache.zookeeper.server.auth.SaslServerCallbackHandler. public static class ClientCallbackHandler implements CallbackHandler { private String password = null; public ClientCallbackHandler(String password) { this.password = password; } public void handle(Callback[] callbacks) throws UnsupportedCallbackException { for (Callback callback : callbacks) { if (callback instanceof NameCallback) { NameCallback nc = (NameCallback) callback; nc.setName(nc.getDefaultName()); } else { if (callback instanceof PasswordCallback) { PasswordCallback pc = (PasswordCallback)callback; if (password != null) { pc.setPassword(this.password.toCharArray()); } else { LOG.warn("Could not login: the client is being asked for a password, but the Zookeeper" + " client code does not currently support obtaining a password from the user." + " Make sure that the client is configured to use a ticket cache (using" + " the JAAS configuration setting 'useTicketCache=true)' and restart the client. If" + " you still get this message after that, the TGT in the ticket cache has expired and must" + " be manually refreshed. To do so, first determine if you are using a password or a" + " keytab. If the former, run kinit in a Unix shell in the environment of the user who" + " is running this Zookeeper client using the command" + " 'kinit <princ>' (where <princ> is the name of the client's Kerberos principal)." + " If the latter, do" + " 'kinit -k -t <keytab> <princ>' (where <princ> is the name of the Kerberos principal, and" + " <keytab> is the location of the keytab file). After manually refreshing your cache," + " restart this client. If you continue to see this message after manually refreshing" + " your cache, ensure that your KDC host's clock is in sync with this host's clock."); } } else { if (callback instanceof RealmCallback) { RealmCallback rc = (RealmCallback) callback; rc.setText(rc.getDefaultText()); } else { if (callback instanceof AuthorizeCallback) { AuthorizeCallback ac = (AuthorizeCallback) callback; String authid = ac.getAuthenticationID(); String authzid = ac.getAuthorizationID(); if (authid.equals(authzid)) { ac.setAuthorized(true); } else { ac.setAuthorized(false); } if (ac.isAuthorized()) { ac.setAuthorizedID(authzid); } } else { throw new UnsupportedCallbackException(callback,"Unrecognized SASL ClientCallback"); } } } } } } } public boolean clientTunneledAuthenticationInProgress() { // TODO: Rather than checking a disjunction here, should be a single member // variable or method in this class to determine whether the client is // configured to use SASL. (see also ZOOKEEPER-1455). try { if ((System.getProperty(Environment.JAAS_CONF_KEY) != null) || ((javax.security.auth.login.Configuration.getConfiguration() != null) && (javax.security.auth.login.Configuration.getConfiguration(). getAppConfigurationEntry(System. getProperty(ZooKeeperSaslClient.LOGIN_CONTEXT_NAME_KEY,"Client")) != null))) { // Client is configured to use a valid login Configuration, so // authentication is either in progress, successful, or failed. // 1. Authentication hasn't finished yet: we must wait for it to do so. if ((isComplete() == false) && (isFailed() == false)) { return true; } // 2. SASL authentication has succeeded or failed.. if (isComplete() || isFailed()) { if (gotLastPacket == false) { // ..but still in progress, because there is a final SASL // message from server which must be received. return true; } } } // Either client is not configured to use a tunnelled authentication // scheme, or tunnelled authentication has completed (successfully or // not), and all server SASL messages have been received. return false; } catch (SecurityException e) { // Thrown if the caller does not have permission to retrieve the Configuration. // In this case, simply returning false is correct. if (LOG.isDebugEnabled() == true) { LOG.debug("Could not retrieve login configuration: " + e); } return false; } } }