/** * Copyright 2015 BlackBerry, Limited. * * Licensed 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 com.blackberry.bdp.krackle.auth; import com.blackberry.bdp.krackle.exceptions.MissingConfigurationException; import com.blackberry.bdp.krackle.exceptions.InvalidConfigurationTypeException; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.net.Socket; import java.util.Arrays; import java.security.PrivilegedActionException; import java.security.PrivilegedExceptionAction; import java.util.Map; 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.sasl.AuthorizeCallback; import javax.security.sasl.RealmCallback; import javax.security.sasl.Sasl; import javax.security.sasl.SaslClient; import javax.security.sasl.SaslException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class SaslPlainTextAuthenticator implements Authenticator{ public enum SaslState { INITIAL, INTERMEDIATE, COMPLETE, FAILED } private static final Logger LOG = LoggerFactory.getLogger(SaslPlainTextAuthenticator.class); // Configurable Items private String hostname; private Subject subject; private String servicePrincipal; private SaslClient saslClient; private String clientPrincipal; private boolean configured; private final Socket socket; private final DataInputStream inStream; private final DataOutputStream outStream; private static final byte[] EMPTY = new byte[0]; private SaslState saslState; /** * Will create a socket based on host name and port * @param hostname * @param port * @throws IOException * @throws SaslException */ public SaslPlainTextAuthenticator(String hostname, int port) throws IOException, SaslException { this(new Socket(hostname, port)); } /** * Will use an existing socket * @param socket * @throws IOException * @throws SaslException */ public SaslPlainTextAuthenticator(Socket socket) throws IOException, SaslException { this.socket = socket; this.inStream = new DataInputStream(socket.getInputStream()); this.outStream = new DataOutputStream(socket.getOutputStream()); this.configured = false; saslState = SaslState.INITIAL; } @Override public void configure(Map<String, ?> configs) throws MissingConfigurationException, InvalidConfigurationTypeException, SaslException { // No longer required to be specified in config map allows config map to be shared hostname = socket.getInetAddress().getHostName(); if (!configs.containsKey("subject")) { throw new MissingConfigurationException("`subject` not defined in configration"); } else if (!configs.get("subject").getClass().equals(Subject.class)) { String type = Subject.class.getCanonicalName(); throw new InvalidConfigurationTypeException("`subject` is not a " + type); } else { subject = (Subject) configs.get("subject"); } if (!configs.containsKey("servicePrincipal")) { throw new MissingConfigurationException("`servicePrincipal` not defined in configration"); } else if (!configs.get("servicePrincipal").getClass().equals(String.class)) { String type = String.class.getCanonicalName(); throw new InvalidConfigurationTypeException("`servicePrincipal` is not a " + type); } else { servicePrincipal = (String) configs.get("servicePrincipal"); } if (!configs.containsKey("clientPrincipal")) { throw new MissingConfigurationException("`clientPrincipal` not defined in configration"); } else if (!configs.get("clientPrincipal").getClass().equals(String.class)) { String type = String.class.getCanonicalName(); throw new InvalidConfigurationTypeException("`clientPrincipal` is not a " + type); } else { clientPrincipal = (String) configs.get("clientPrincipal"); } this.saslClient = createSaslClient(); configured = true; LOG.info("authenticator has been configured"); } private SaslClient createSaslClient() throws SaslException { try { return Subject.doAs(subject, new PrivilegedExceptionAction<SaslClient>() { @Override public SaslClient run() throws SaslException { String[] mechs = {"GSSAPI"}; LOG.info("Creating SaslClient: client={}; service={}; serviceHostname={}; mechs={}", clientPrincipal, servicePrincipal, hostname, Arrays.toString(mechs)); return Sasl.createSaslClient(mechs, clientPrincipal, servicePrincipal, hostname, null, new ClientCallbackHandler()); } }); } catch (PrivilegedActionException e) { throw new SaslException("Failed to create SaslClient", e.getCause()); } } /** * Sends an empty message to the server to initiate the authentication process. It then evaluates server challenges * via `SaslClient.evaluateChallenge` and returns client responses until authentication succeeds or fails. * * The messages are sent and received as size delimited bytes that consists of a 4 byte network-ordered size N * followed by N bytes representing the opaque payload. * @throws java.io.IOException */ @Override public void authenticate() throws IOException { if (!configured) { throw new IOException("authentication attempted on unconfigured authenticator"); } while (!saslClient.isComplete()) { switch (saslState) { case INITIAL: LOG.debug("saslClient has initial response? {}", saslClient.hasInitialResponse()); sendSaslToken(EMPTY); saslState = SaslState.INTERMEDIATE; LOG.debug("sent initial empty sasl token"); break; case INTERMEDIATE: byte[] challenge; LOG.debug("in intermediate"); int length = inStream.readInt(); LOG.debug("in intermediate - read int, length of response is {}", length); challenge = new byte[length]; inStream.readFully(challenge); LOG.debug("read response"); sendSaslToken(challenge); if (saslClient.isComplete()) { LOG.debug("complete sasl state detected in intermediate"); saslState = SaslState.COMPLETE; } break; case COMPLETE: break; case FAILED: throw new IOException("SASL handshake failed"); } } LOG.debug("authentication complete"); } private void sendSaslToken(byte[] serverToken) throws IOException { if (!saslClient.isComplete()) { try { byte[] saslToken = createSaslToken(serverToken); if (saslToken != null) { LOG.debug("sending sasl token of length: {}", saslToken.length); outStream.writeInt(saslToken.length); outStream.write(saslToken); outStream.flush(); LOG.debug("sent sasl token of length: {}", saslToken.length); } } catch (IOException e) { saslState = SaslState.FAILED; throw e; } } else { LOG.warn("attempting to send sasl token to a completed sasl client"); } } private byte[] createSaslToken(final byte[] saslToken) throws SaslException { if (saslToken == null) { throw new SaslException("Error authenticating with the Kafka Broker: received a nul saslToken."); } try { return Subject.doAs(subject, new PrivilegedExceptionAction<byte[]>() { @Override public byte[] run() throws SaslException { LOG.debug("evaluating challenge of length {} to {}", saslToken.length, socket.getInetAddress().getHostAddress()); byte[] evaluation = saslClient.evaluateChallenge(saslToken); LOG.debug("evaluation length is {}", evaluation.length); return evaluation; } }); } catch (PrivilegedActionException e) { String error = "An error: (" + e + ") occurred when evaluating SASL token received from the Kafka Broker."; // 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 unknownServerErrorText = "(Mechanism level: Server not found in Kerberos database (7) - UNKNOWN_SERVER)"; if (e.toString().contains(unknownServerErrorText)) { error += " This may be caused by Java's being unable to resolve the Kafka Broker's" + " hostname correctly. You may want to try to adding" + " '-Dsun.net.spi.nameservice.provider.1=dns,sun' to your client's JVMFLAGS environment." + " Users must configure FQDN of kafka brokers when authenticating using SASL and" + " `socketChannel.socket().getInetAddress().getHostName()` must match the hostname in `principal/hostname@realm`"; } error += " Kafka Client will go to AUTH_FAILED state."; //Unwrap the SaslException inside `PrivilegedActionException` throw new SaslException(error, e.getCause()); } } private static class ClientCallbackHandler implements CallbackHandler { @Override public void handle(Callback[] callbacks) throws UnsupportedCallbackException { for (Callback callback : callbacks) { LOG.info("callback {} received", callback.toString()); if (callback instanceof NameCallback) { NameCallback nc = (NameCallback) callback; nc.setName(nc.getDefaultName()); } else { if (callback instanceof PasswordCallback) { // Call `setPassword` once we support obtaining a password from the user and update message below throw new UnsupportedCallbackException(callback, "Could not login: the client is being asked for a password, but the Kafka" + " client code does not currently support obtaining a password from the user." + " Make sure -Djava.security.auth.login.config property passed to JVM and" + " the client is configured to use a ticket cache (using" + " the JAAS configuration setting 'useTicketCache=true)'. Make sure you are using" + " FQDN of the Kafka broker you are trying to connect to."); } 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(); ac.setAuthorized(authId.equals(authzId)); if (ac.isAuthorized()) { ac.setAuthorizedID(authzId); } } else { throw new UnsupportedCallbackException(callback, "Unrecognized SASL ClientCallback"); } } } } } } } @Override public boolean complete() { return saslState == SaslState.COMPLETE; } @Override public void close() throws IOException { saslClient.dispose(); } @Override public Socket getSocket() { return socket; } @Override public boolean configured() { return configured; } }