blob: 449e7be6a9ceb49b1fb317f68f9ce862cdbcc1df [file] [log] [blame]
/**
* 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 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;
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.common.ZKConfig;
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.ietf.jgss.GSSContext;
import org.ietf.jgss.GSSCredential;
import org.ietf.jgss.GSSException;
import org.ietf.jgss.GSSManager;
import org.ietf.jgss.Oid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class manages SASL authentication for the client. It
* allows ClientCnxn to authenticate using SASL with a ZooKeeper server.
*/
public class ZooKeeperSaslClient {
/**
* @deprecated Use {@link ZKClientConfig#LOGIN_CONTEXT_NAME_KEY}
* instead.
*/
@Deprecated
public static final String LOGIN_CONTEXT_NAME_KEY = "zookeeper.sasl.clientconfig";
/**
* @deprecated Use {@link ZKClientConfig#ENABLE_CLIENT_SASL_KEY}
* instead.
*/
@Deprecated
public static final String ENABLE_CLIENT_SASL_KEY = "zookeeper.sasl.client";
/**
* @deprecated Use {@link ZKClientConfig#ENABLE_CLIENT_SASL_DEFAULT}
* instead.
*/
@Deprecated
public static final String ENABLE_CLIENT_SASL_DEFAULT = "true";
private volatile boolean initializedLogin = false;
/**
* Returns true if the SASL client is enabled. By default, the client
* is enabled but can be disabled by setting the system property
* <code>zookeeper.sasl.client</code> to <code>false</code>. See
* ZOOKEEPER-1657 for more information.
*
* @return true if the SASL client is enabled.
* @deprecated Use {@link ZKClientConfig#isSaslClientEnabled} instead
*/
@Deprecated
public static boolean isEnabled() {
return Boolean.valueOf(System.getProperty(
ZKClientConfig.ENABLE_CLIENT_SASL_KEY,
ZKClientConfig.ENABLE_CLIENT_SASL_DEFAULT));
}
private static final Logger LOG = LoggerFactory.getLogger(ZooKeeperSaslClient.class);
private Login login = null;
private SaslClient saslClient;
private boolean isSASLConfigured = true;
private final ZKClientConfig clientConfig;
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, ZKClientConfig clientConfig) throws LoginException {
/**
* ZOOKEEPER-1373: allow system property to specify the JAAS
* configuration section that the zookeeper client should use.
* Default to "Client".
*/
String clientSection = clientConfig.getProperty(
ZKClientConfig.LOGIN_CONTEXT_NAME_KEY,
ZKClientConfig.LOGIN_CONTEXT_NAME_KEY_DEFAULT);
this.clientConfig = clientConfig;
// Note that 'Configuration' here refers to javax.security.auth.login.Configuration.
AppConfigurationEntry entries[] = null;
RuntimeException runtimeException = 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.
runtimeException = e;
} catch (IllegalArgumentException e) {
// third party customized getAppConfigurationEntry could throw IllegalArgumentException when JAAS
// configuration isn't set. We can reevaluate whether to catch RuntimeException instead when more
// different types of RuntimeException found
runtimeException = 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 = clientConfig
.getProperty(ZKClientConfig.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 (runtimeException != null) {
throw new LoginException(
"Zookeeper client cannot authenticate using the "
+ explicitClientSection
+ " section of the supplied JAAS configuration: '"
+ clientConfig.getJaasConfKey() + "' because of a "
+ "RuntimeException: " + runtimeException);
} 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 (runtimeException != null) {
msg += "(" + runtimeException + ")";
} else {
msg += "(unknown error)";
}
this.configStatus = msg;
this.isSASLConfigured = false;
}
if (clientConfig.getJaasConfKey() != null) {
// Again, the user explicitly set something SASL-related, so
// they probably expected SASL to succeed.
if (runtimeException != null) {
throw new LoginException(
"Zookeeper client cannot authenticate using the '"
+ clientConfig.getProperty(
ZKClientConfig.LOGIN_CONTEXT_NAME_KEY,
ZKClientConfig.LOGIN_CONTEXT_NAME_KEY_DEFAULT)
+ "' section of the supplied JAAS configuration: '"
+ clientConfig.getJaasConfKey() + "' because of a "
+ "RuntimeException: " + runtimeException);
} else {
throw new LoginException(
"No JAAS configuration section named '"
+ clientConfig.getProperty(
ZKClientConfig.LOGIN_CONTEXT_NAME_KEY,
ZKClientConfig.LOGIN_CONTEXT_NAME_KEY_DEFAULT)
+ "' was found in specified JAAS configuration file: '"
+ clientConfig.getJaasConfKey() + "'.");
}
}
}
}
/**
* @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);
}
}
private SaslClient createSaslClient(final String servicePrincipal, final String loginContext)
throws LoginException {
try {
if (!initializedLogin) {
synchronized (this) {
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.
// in order to ensure the login is initialized only once, it must be synchronized the code snippet.
login = new Login(loginContext, new ClientCallbackHandler(null), clientConfig);
login.startThreadIfNeeded();
initializedLogin = true;
}
}
}
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.
boolean usingNativeJgss = clientConfig.getBoolean(ZKConfig.JGSS_NATIVE);
if (usingNativeJgss) {
// http://docs.oracle.com/javase/6/docs/technotes/guides/security/jgss/jgss-features.html
// """
// In addition, when performing operations as a particular
// Subject, e.g. Subject.doAs(...) or Subject.doAsPrivileged(...),
// the to-be-used GSSCredential should be added to Subject's
// private credential set. Otherwise, the GSS operations will
// fail since no credential is found.
// """
try {
GSSManager manager = GSSManager.getInstance();
Oid krb5Mechanism = new Oid("1.2.840.113554.1.2.2");
GSSCredential cred = manager.createCredential(null,
GSSContext.DEFAULT_LIFETIME,
krb5Mechanism,
GSSCredential.INITIATE_ONLY);
subject.getPrivateCredentials().add(cred);
if (LOG.isDebugEnabled()) {
LOG.debug("Added private credential to subject: " + cred);
}
} catch (GSSException ex) {
LOG.warn("Cannot add private credential to subject; " +
"authentication at the server may fail", ex);
}
}
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 = clientConfig.getProperty(
ZKClientConfig.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("Exception while trying to create 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().equals("GSSAPI")))
gotLastPacket = true;
// non-GSSAPI: no final packet from server.
if (!saslClient.getMechanismName().equals("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.saslCompleted();
}
}
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() {
if (!isSASLConfigured) {
return false;
}
// 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 ((clientConfig.getJaasConfKey() != null)
|| ((Configuration.getConfiguration() != null) && (Configuration.getConfiguration()
.getAppConfigurationEntry(clientConfig.getProperty(ZKClientConfig.LOGIN_CONTEXT_NAME_KEY,
ZKClientConfig.LOGIN_CONTEXT_NAME_KEY_DEFAULT)) != 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;
}
}
/**
* close login thread if running
*/
public void shutdown() {
if (null != login) {
login.shutdown();
}
}
}