/* This file is part of VoltDB.
* Copyright (C) 2008-2017 VoltDB Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with VoltDB. If not, see <http://www.gnu.org/licenses/>.
*/
package org.voltdb;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jetty.continuation.Continuation;
import org.eclipse.jetty.continuation.ContinuationListener;
import org.eclipse.jetty.continuation.ContinuationSupport;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.util.B64Code;
import org.ietf.jgss.GSSContext;
import org.ietf.jgss.GSSException;
import org.ietf.jgss.GSSManager;
import org.ietf.jgss.GSSName;
import org.ietf.jgss.Oid;
import org.voltcore.logging.Level;
import org.voltcore.logging.VoltLogger;
import org.voltcore.utils.EstTime;
import org.voltcore.utils.RateLimitedLogger;
import org.voltdb.AuthSystem.AuthUser;
import org.voltdb.VoltDB.Configuration;
import org.voltdb.client.ClientAuthScheme;
import org.voltdb.client.ClientResponse;
import org.voltdb.client.ProcedureCallback;
import org.voltdb.security.AuthenticationRequest;
import org.voltdb.utils.Base64;
import org.voltdb.utils.Encoder;
import com.google_voltpatches.common.base.Supplier;
import com.google_voltpatches.common.base.Suppliers;
import com.google_voltpatches.common.base.Throwables;
public class HTTPClientInterface {
public static final String QUERY_TIMEOUT_PARAM = "Querytimeout";
public static final String JSONP = "jsonp";
public static final Pattern JSONP_PATTERN = Pattern.compile("^[a-zA-Z0-9_$]*$");
private static final VoltLogger m_log = new VoltLogger("HOST");
private static final RateLimitedLogger m_rate_limited_log = new RateLimitedLogger(10 * 1000, m_log, Level.WARN);
static final int CACHE_TARGET_SIZE = 10;
public static final String PARAM_USERNAME = "User";
public static final String PARAM_PASSWORD = "Password";
public static final String PARAM_HASHEDPASSWORD = "Hashedpassword";
public static final String PARAM_ADMIN = "admin";
int m_timeout = 0;
final boolean m_spnegoEnabled;
final String m_servicePrincipal;
final String m_timeoutResponse;
private final Supplier<InternalConnectionHandler> m_invocationHandler =
Suppliers.memoize(new Supplier<InternalConnectionHandler>() {
@Override
public InternalConnectionHandler get() {
ClientInterface ci = VoltDB.instance().getClientInterface();
if (ci == null || !ci.isAcceptingConnections()) {
throw new IllegalStateException("Client interface is not ready to be used or has been closed.");
}
return ci.getInternalConnectionHandler();
}
});
public final static int MAX_QUERY_PARAM_SIZE = 2 * 1024 * 1024; // 2MB
public final static int MAX_FORM_KEYS = 512;
public void setTimeout(int seconds) {
m_timeout = seconds * 1000;
}
class JSONProcCallback implements ProcedureCallback, ContinuationListener {
final AtomicBoolean m_complete = new AtomicBoolean(false);
final Continuation m_continuation;
final String m_jsonp;
public JSONProcCallback(Continuation continuation, String jsonp) {
assert continuation != null : "given continuation is null";
m_continuation = continuation;
m_continuation.addContinuationListener(this);
m_jsonp = jsonp;
}
@Override
public void clientCallback(ClientResponse clientResponse) throws Exception {
if (!m_complete.compareAndSet(false, true)) {
if (clientResponse.getStatus() != ClientResponse.RESPONSE_UNKNOWN) {
m_rate_limited_log.log(
EstTime.currentTimeMillis(), Level.WARN, null,
"Procedure response arrived for a request that was timed out by jetty"
);
}
return;
}
ClientResponseImpl rimpl = (ClientResponseImpl) clientResponse;
String msg = rimpl.toJSONString();
// handle jsonp pattern
// http://en.wikipedia.org/wiki/JSON#The_Basic_Idea:_Retrieving_JSON_via_Script_Tags
msg = asJsonp(m_jsonp, msg);
m_continuation.setAttribute("result", msg);
try {
m_continuation.resume();
} catch (IllegalStateException e) {
// Thrown when we shut down the server via the JSON/HTTP (web studio) API
// Essentially we're closing everything down from underneath the HTTP request.
m_log.warn("JSON request cannot be completed. The server is shutting down. " + e.getMessage());
}
}
@Override
public void onComplete(Continuation continuation) {
if(!m_complete.get()) {
m_complete.compareAndSet(false, true);
}
}
@Override
public void onTimeout(Continuation continuation) {
if (m_complete.compareAndSet(false, true)) {
m_continuation.setAttribute("result", m_timeoutResponse);
m_continuation.resume();
}
}
}
public HTTPClientInterface() {
final ClientResponseImpl r = new ClientResponseImpl(ClientResponse.CONNECTION_TIMEOUT,
new VoltTable[0], "Request Timeout");
m_timeoutResponse = r.toJSONString();
m_servicePrincipal = getAuthSystem().getServicePrincipal();
m_spnegoEnabled = m_servicePrincipal != null && !m_servicePrincipal.isEmpty();
}
public void stop() {
}
public final static String asJsonp(String jsonp, String msg) {
if (jsonp == null) return msg;
StringBuilder sb = new StringBuilder(jsonp.length() + msg.length() + 8);
return sb.append(jsonp).append("( ").append(msg).append(" )").toString();
}
private final static void simpleJsonResponse(String jsonp, String message, HttpServletResponse rsp, int code) {
ClientResponseImpl rimpl = new ClientResponseImpl(
ClientResponse.UNEXPECTED_FAILURE, new VoltTable[0], message);
String msg = rimpl.toJSONString();
msg = asJsonp(jsonp, msg);
rsp.setStatus(code);
try {
rsp.getWriter().print(msg);
rsp.getWriter().flush();
} catch (IOException ignoreThisAsBrowserMustHaveClosed) {
}
}
private final static void badRequest(String jsonp, String message, HttpServletResponse rsp) {
simpleJsonResponse(jsonp, message, rsp, HttpServletResponse.SC_BAD_REQUEST);
}
private final static void unauthorized(String jsonp, String message, HttpServletResponse rsp) {
simpleJsonResponse(jsonp, message, rsp, HttpServletResponse.SC_UNAUTHORIZED);
}
private final static void ok(String jsonp, String message, HttpServletResponse rsp) {
simpleJsonResponse(jsonp, message, rsp, HttpServletResponse.SC_OK);
}
public static boolean validateJSONP(String jsonp, Request request, HttpServletResponse response) {
if (jsonp != null && !JSONP_PATTERN.matcher(jsonp).matches()) {
badRequest(null, "Invalid jsonp callback function name", response);
request.setHandled(true);
return false;
}
return true;
}
public void process(Request request, HttpServletResponse response) {
AuthenticationResult authResult = null;
boolean suspended = false;
String jsonp = request.getHeader(JSONP);
if (!validateJSONP(jsonp, request, response)) {
return;
}
String authHeader = request.getHeader(HttpHeader.AUTHORIZATION.asString());
if (m_spnegoEnabled && (authHeader == null || !authHeader.startsWith(HttpHeader.NEGOTIATE.asString()))) {
m_log.debug("SpengoAuthenticator: sending challenge");
response.setHeader(HttpHeader.WWW_AUTHENTICATE.asString(), HttpHeader.NEGOTIATE.asString());
unauthorized(jsonp, "must initiate SPNEGO negotiation", response);
request.setHandled(true);
return;
}
final Continuation continuation = ContinuationSupport.getContinuation(request);
String result = (String)continuation.getAttribute("result");
if (result != null) {
try {
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().print(result);
request.setHandled(true);
} catch (IllegalStateException | IOException e){
// Thrown when we shut down the server via the JSON/HTTP (web studio) API
// Essentially we're closing everything down from underneath the HTTP request.
m_log.warn("JSON failed to send response: ", e);
}
return;
}
//Check if this is resumed request.
if (Boolean.TRUE.equals(continuation.getAttribute("SQLSUBMITTED"))) {
try {
continuation.suspend(response);
} catch (IllegalStateException e){
// Thrown when we shut down the server via the JSON/HTTP (web studio) API
// Essentially we're closing everything down from underneath the HTTP request.
m_log.warn("JSON request completion exception in process: ", e);
}
return;
}
if (m_timeout > 0 && continuation.isInitial()) {
continuation.setTimeout(m_timeout);
}
try {
if (request.getMethod().equalsIgnoreCase("POST")) {
int queryParamSize = request.getContentLength();
if (queryParamSize > MAX_QUERY_PARAM_SIZE) {
ok(jsonp, "Query string too large: " + String.valueOf(request.getContentLength()), response);
request.setHandled(true);
return;
}
if (queryParamSize == 0) {
ok(jsonp, "Received POST with no parameters in the body.", response);
request.setHandled(true);
return;
}
}
if (jsonp == null) {
jsonp = request.getParameter(JSONP);
if (!validateJSONP(jsonp, request, response)) {
return;
}
}
String procName = request.getParameter("Procedure");
String params = request.getParameter("Parameters");
String timeoutStr = request.getParameter(QUERY_TIMEOUT_PARAM);
// null procs are bad news
if (procName == null) {
badRequest(jsonp, "Procedure parameter is missing", response);
request.setHandled(true);
return;
}
int queryTimeout = -1;
if (timeoutStr != null) {
try {
queryTimeout = Integer.parseInt(timeoutStr);
if (queryTimeout <= 0) {
throw new NumberFormatException("negative query timeout");
}
} catch(NumberFormatException e) {
badRequest(jsonp, "invalid query timeout: " + timeoutStr, response);
request.setHandled(true);
return;
}
}
authResult = authenticate(request);
if (!authResult.isAuthenticated()) {
unauthorized(jsonp, authResult.m_message, response);
request.setHandled(true);
return;
}
continuation.suspend(response);
suspended = true;
JSONProcCallback cb = new JSONProcCallback(continuation, jsonp);
boolean success;
if (params != null) {
ParameterSet paramSet = null;
try {
paramSet = ParameterSet.fromJSONString(params);
}
// if decoding params has a fail, then fail
catch (Exception e) {
badRequest(jsonp, "failed to parse invocation parameters", response);
request.setHandled(true);
continuation.complete();
return;
}
// if the paramset has content, but decodes to null, fail
if (paramSet == null) {
badRequest(jsonp, "failed to decode invocation parameters", response);
request.setHandled(true);
continuation.complete();
return;
}
success = callProcedure(authResult, queryTimeout, cb, procName, paramSet.toArray());
}
else {
success = callProcedure(authResult, queryTimeout, cb, procName);
}
if (!success) {
ok(jsonp, "Server is not accepting work at this time.", response);
request.setHandled(true);
continuation.complete();
return;
}
if (jsonp != null) {
request.setAttribute("jsonp", jsonp);
}
continuation.setAttribute("SQLSUBMITTED", Boolean.TRUE);
} catch (Exception e) {
String msg = Throwables.getStackTraceAsString(e);
m_rate_limited_log.log(EstTime.currentTimeMillis(), Level.WARN, e, "JSON interface exception");
ok(jsonp, msg, response);
if (suspended) {
continuation.complete();
}
request.setHandled(true);
}
}
public boolean callProcedure(final AuthenticationResult ar, int timeout, ProcedureCallback cb, String procName, Object...args) {
return m_invocationHandler.get().callProcedure(ar.m_authUser, ar.m_adminMode, timeout, cb, procName, args);
}
public boolean callProcedure(final AuthUser user, boolean adminMode, int timeout, ProcedureCallback cb, String procName, Object...args) {
return m_invocationHandler.get().callProcedure(user, adminMode, timeout, cb, procName, args);
}
Configuration getVoltDBConfig() {
return VoltDB.instance().getConfig();
}
private AuthenticationResult getAuthenticationResult(Request request) {
boolean adminMode = false;
String username = null;
String hashedPassword = null;
String password = null;
String token = null;
//Check authorization header
String auth = request.getHeader(HttpHeader.AUTHORIZATION.asString());
boolean validAuthHeader = false;
if (auth != null) {
String schemeAndHandle[] = auth.split(" ");
if (auth.startsWith(HttpHeader.NEGOTIATE.asString())) {
token = (auth.length() >= 10 ? auth.substring(10) : "");
validAuthHeader = true;
} else if (schemeAndHandle.length == 2) {
if (schemeAndHandle[0].equalsIgnoreCase("hashed")) {
String up[] = schemeAndHandle[1].split(":");
if (up.length == 2) {
username = up[0];
hashedPassword = up[1];
validAuthHeader = true;
}
} else if (schemeAndHandle[0].equalsIgnoreCase("basic")) {
String unpw = new String(Base64.decode(schemeAndHandle[1]));
String up[] = unpw.split(":");
if (up.length == 2) {
username = up[0];
password = up[1];
validAuthHeader = true;
}
}
}
}
if (!validAuthHeader) {
username = request.getParameter(PARAM_USERNAME);
hashedPassword = request.getParameter(PARAM_HASHEDPASSWORD);
password = request.getParameter(PARAM_PASSWORD);
}
String admin = request.getParameter(PARAM_ADMIN);
adminMode = "true".equalsIgnoreCase(admin);
// The SHA-1 hash of the password
byte[] hashedPasswordBytes = null;
if (password != null) {
try {
// Create a MessageDigest every time because MessageDigest is not thread safe (ENG-5438)
MessageDigest md = MessageDigest.getInstance(ClientAuthScheme.getDigestScheme(ClientAuthScheme.HASH_SHA256));
hashedPasswordBytes = md.digest(password.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
return new AuthenticationResult(false, null, adminMode, username, "JVM doesn't support SHA-256 hashing. Please use a supported JVM" + e);
}
}
// note that HTTP Var "Hashedpassword" has a higher priority
// Hashedassword must be a 40-byte hex-encoded SHA-1 hash (20 bytes unencoded)
// OR
// Hashedassword must be a 64-byte hex-encoded SHA-256 hash (32 bytes unencoded)
if (hashedPassword != null) {
if (hashedPassword.length() != 40 && hashedPassword.length() != 64) {
return new AuthenticationResult(false, null, adminMode, username, "Hashedpassword must be a 40-byte hex-encoded SHA-1 hash (20 bytes unencoded). "
+ "or 64-byte hex-encoded SHA-256 hash (32 bytes unencoded)");
}
try {
hashedPasswordBytes = Encoder.hexDecode(hashedPassword);
}
catch (Exception e) {
return new AuthenticationResult(false, null, adminMode, username, "Hashedpassword must be a 40-byte hex-encoded SHA-1 hash (20 bytes unencoded). "
+ "or 64-byte hex-encoded SHA-256 hash (32 bytes unencoded)");
}
}
assert((hashedPasswordBytes == null) || (hashedPasswordBytes.length == 20) || (hashedPasswordBytes.length == 32));
String fromAddress = request.getRemoteAddr();
if (fromAddress == null) fromAddress = "NULL";
if (m_spnegoEnabled) {
final String principal = spnegoLogin(token);
AuthenticationRequest authReq = getAuthSystem().new SpnegoPassthroughRequest(principal);
if (!authReq.authenticate(ClientAuthScheme.SPNEGO, fromAddress)) {
return new AuthenticationResult(
false, null, adminMode, principal,
"User " + principal + " from " + fromAddress + " failed to authenticate"
);
}
return new AuthenticationResult(true, ClientAuthScheme.SPNEGO, adminMode, principal, "");
} else {
AuthenticationRequest authReq = getAuthSystem().new HashAuthenticationRequest(username, hashedPasswordBytes);
ClientAuthScheme scheme = hashedPasswordBytes != null ?
ClientAuthScheme.getByUnencodedLength(hashedPasswordBytes.length)
: ClientAuthScheme.HASH_SHA256;
if (!authReq.authenticate(scheme, fromAddress)) {
return new AuthenticationResult(
false, null, adminMode, username,
"User " + username + " from " + fromAddress + " failed to authenticate"
);
}
return new AuthenticationResult(true, scheme, adminMode, username, "");
}
}
private String spnegoLogin(String encodedToken) {
byte[] token = B64Code.decode(encodedToken);
try {
if (encodedToken == null || encodedToken.isEmpty()) {
return null;
}
final Oid spnegoOid = new Oid("1.3.6.1.5.5.2");
GSSManager manager = GSSManager.getInstance();
GSSName name = manager.createName(m_servicePrincipal, null);
GSSContext ctx = manager.createContext(
name.canonicalize(spnegoOid), spnegoOid,
null, GSSContext.INDEFINITE_LIFETIME
);
if (ctx == null) {
m_rate_limited_log.log(
EstTime.currentTimeMillis(),
Level.ERROR, null,
"Failed to establish security context for SPNEGO authentication"
);
return null;
}
while (!ctx.isEstablished()) {
token = ctx.acceptSecContext(token, 0, token.length);
}
if (ctx.isEstablished()) {
if (ctx.getSrcName() == null) {
m_rate_limited_log.log(
EstTime.currentTimeMillis(),
Level.ERROR, null,
"Failed to read source name from established SPNEGO security context"
);
return null;
}
String user = ctx.getSrcName().toString();
if (m_log.isDebugEnabled()) {
m_log.debug("established SPNEGO security context for " + user);
}
return user;
}
return null;
} catch (GSSException e) {
m_rate_limited_log.log(EstTime.currentTimeMillis(), Level.ERROR, e, "failed SPNEGO authentication");
return null;
}
}
AuthSystem getAuthSystem() {
return VoltDB.instance().getCatalogContext().authSystem;
}
//Remember to call releaseClient if you authenticate which will close admin clients and refcount-- others.
public AuthenticationResult authenticate(Request request) {
AuthenticationResult authResult = getAuthenticationResult(request);
if (!authResult.isAuthenticated()) {
m_rate_limited_log.log("JSON interface exception: " + authResult.m_message, EstTime.currentTimeMillis());
}
return authResult;
}
public void notifyOfCatalogUpdate() {
// NOOP
}
}