From 070d556605d133e7ea937855c82c44325ebec0d5 Mon Sep 17 00:00:00 2001 From: magnum Date: Mon, 1 Jun 2026 09:37:46 +0900 Subject: [PATCH] HIVE-29639: Support a pluggable authentication filter for the HiveServer2 WebUI --- .../org/apache/hadoop/hive/conf/HiveConf.java | 6 + .../java/org/apache/hive/http/HttpServer.java | 42 ++++ .../http/TestHttpServerCustomAuthFilter.java | 231 ++++++++++++++++++ .../resources/hive-webapps/test/index.html | 1 + .../hive/service/server/HiveServer2.java | 17 +- .../hive/service/server/TestHiveServer2.java | 80 ++++++ 6 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 common/src/test/org/apache/hive/http/TestHttpServerCustomAuthFilter.java create mode 100644 common/src/test/resources/hive-webapps/test/index.html diff --git a/common/src/java/org/apache/hadoop/hive/conf/HiveConf.java b/common/src/java/org/apache/hadoop/hive/conf/HiveConf.java index 31b5e32c2ddb..6e9af4e17715 100644 --- a/common/src/java/org/apache/hadoop/hive/conf/HiveConf.java +++ b/common/src/java/org/apache/hadoop/hive/conf/HiveConf.java @@ -3865,6 +3865,12 @@ public static enum ConfVars { + " for HiveServer2 WebUI."), HIVE_SERVER2_WEBUI_SSL_KEYMANAGERFACTORY_ALGORITHM("hive.server2.webui.keymanagerfactory.algorithm", "","SSL certificate key manager factory algorithm for HiveServer2 WebUI."), + HIVE_SERVER2_WEBUI_USE_CUSTOM_AUTH_FILTER("hive.server2.webui.use.custom.auth.filter", false, + "If true, the HiveServer2 WebUI will be secured with custom auth filter"), + HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER("hive.server2.webui.custom.auth.filter", "", + "Filter class name to apply to the Web UI. The filter should be a standard javax servlet Filter. " + + "Filter parameters can also be specified in the configuration, by setting config entries of the form " + + "hive.server2.webui.custom.auth.filter.param.="), HIVE_SERVER2_WEBUI_USE_SPNEGO("hive.server2.webui.use.spnego", false, "If true, the HiveServer2 WebUI will be secured with SPNEGO. Clients must authenticate with Kerberos."), HIVE_SERVER2_WEBUI_SPNEGO_KEYTAB("hive.server2.webui.spnego.keytab", "", diff --git a/common/src/java/org/apache/hive/http/HttpServer.java b/common/src/java/org/apache/hive/http/HttpServer.java index dd9e66f92b6b..52a003d3d194 100644 --- a/common/src/java/org/apache/hive/http/HttpServer.java +++ b/common/src/java/org/apache/hive/http/HttpServer.java @@ -51,6 +51,7 @@ import javax.servlet.http.HttpServletRequestWrapper; import javax.servlet.http.HttpServletResponse; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import org.apache.commons.lang3.StringUtils; @@ -170,6 +171,12 @@ public static class Builder { private boolean useSPNEGO; private boolean useSSL; private boolean usePAM; + @VisibleForTesting + public boolean useCustomAuthFilter; + @VisibleForTesting + public String customAuthFilter; + @VisibleForTesting + public Map customAuthFilterParams; private boolean enableCORS; private String allowedOrigins; private String allowedMethods; @@ -277,6 +284,21 @@ public Builder setUseSPNEGO(boolean useSPNEGO) { return this; } + public Builder setUseCustomAuthFilter(boolean useCustomAuthFilter) { + this.useCustomAuthFilter = useCustomAuthFilter; + return this; + } + + public Builder setCustomAuthFilter(String customAuthFilter) { + this.customAuthFilter = customAuthFilter; + return this; + } + + public Builder setCustomAuthFilterParams(Map customAuthFilterParams) { + this.customAuthFilterParams = customAuthFilterParams; + return this; + } + public Builder setEnableCORS(boolean enableCORS) { this.enableCORS = enableCORS; return this; @@ -531,6 +553,19 @@ void setupSpnegoFilter(Builder b, ServletContextHandler ctx) throws IOException holder, "/*", FilterMapping.ALL); } + /** + * Secure the web server with a custom {@link javax.servlet.Filter}. + * The filter class name and its init parameters come from the {@link Builder}. + */ + void setupCustomAuthFilter(Builder b, ServletContextHandler ctx) { + FilterHolder holder = new FilterHolder(); + holder.setClassName(b.customAuthFilter); + holder.setInitParameters(b.customAuthFilterParams); + ServletHandler handler = ctx.getServletHandler(); + handler.addFilterWithMapping( + holder, "/*", FilterMapping.ALL); + } + /** * Setup cross-origin requests (CORS) filter. * @param b - builder @@ -607,6 +642,10 @@ private void initWebAppContext(Builder builder, WebAppContext webAppContext) thr setupSpnegoFilter(builder, webAppContext); } + if (builder.useCustomAuthFilter) { + setupCustomAuthFilter(builder, webAppContext); + } + if (builder.enableCORS) { setupCORSFilter(builder, webAppContext); } @@ -796,6 +835,9 @@ private void initializeWebServer(final Builder b) throws IOException { if(b.useSPNEGO) { setupSpnegoFilter(b,logCtx); } + if (b.useCustomAuthFilter) { + setupCustomAuthFilter(b, logCtx); + } logCtx.addServlet(AdminAuthorizedServlet.class, "/*"); logCtx.setResourceBase(logDir); logCtx.setDisplayName("logs"); diff --git a/common/src/test/org/apache/hive/http/TestHttpServerCustomAuthFilter.java b/common/src/test/org/apache/hive/http/TestHttpServerCustomAuthFilter.java new file mode 100644 index 000000000000..2f9dbfacf19d --- /dev/null +++ b/common/src/test/org/apache/hive/http/TestHttpServerCustomAuthFilter.java @@ -0,0 +1,231 @@ +/* + * 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.hive.http; + +import org.apache.hadoop.hive.conf.HiveConf; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URL; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * End-to-end test that wires a sample {@link Filter} into HttpServer via + * the custom-auth-filter Builder API and verifies that real HTTP requests + * to the running server flow through the filter — and that the filter's + * decision (pass through or short-circuit with 401) is honored. + */ +public class TestHttpServerCustomAuthFilter { + + private HttpServer server; + + @Before + public void resetCounters() { + RecordingFilter.reset(); + BlockingFilter.reset(); + } + + @After + public void tearDown() throws Exception { + if (server != null) { + server.stop(); + server = null; + } + } + + /** + * With {@code useCustomAuthFilter=true} and a passthrough filter, requests + * reach the underlying servlet AND the filter sees them, including the + * configured init parameters. + */ + @Test(timeout = 30_000) + public void testCustomAuthFilterInterceptsRequests() throws Exception { + Map params = new HashMap<>(); + params.put("realm", "hive"); + params.put("ttl", "600"); + + int port = freePort(); + server = new HttpServer.Builder("test") + .setConf(new HiveConf()) + .setHost("localhost") + .setPort(port) + .setUseCustomAuthFilter(true) + .setCustomAuthFilter(RecordingFilter.class.getName()) + .setCustomAuthFilterParams(params) + .build(); + server.start(); + + int code = doGet("/jmx"); + assertEquals("Passthrough filter should not block the request", 200, code); + + assertTrue("Filter should have been invoked at least once; was " + + RecordingFilter.callCount.get(), RecordingFilter.callCount.get() >= 1); + assertEquals("Init params should be threaded through to the filter", + "hive", RecordingFilter.initParams.get("realm")); + assertEquals("600", RecordingFilter.initParams.get("ttl")); + } + + /** + * The filter's decision is authoritative: when the filter short-circuits + * with a 401, the underlying servlet is never reached and the client + * receives the 401 response code. + */ + @Test(timeout = 30_000) + public void testCustomAuthFilterCanBlockRequest() throws Exception { + int port = freePort(); + server = new HttpServer.Builder("test") + .setConf(new HiveConf()) + .setHost("localhost") + .setPort(port) + .setUseCustomAuthFilter(true) + .setCustomAuthFilter(BlockingFilter.class.getName()) + .setCustomAuthFilterParams(new HashMap<>()) + .build(); + server.start(); + + int code = doGet("/jmx"); + assertEquals("Blocking filter should short-circuit with 401", 401, code); + assertTrue("Filter should have run before blocking", + BlockingFilter.callCount.get() >= 1); + } + + /** + * Without {@code useCustomAuthFilter=true}, no custom filter is installed + * and requests proceed normally; the recording filter sees nothing even + * though it is present on the classpath. + */ + @Test(timeout = 30_000) + public void testCustomAuthFilterNotInstalledWhenDisabled() throws Exception { + int port = freePort(); + server = new HttpServer.Builder("test") + .setConf(new HiveConf()) + .setHost("localhost") + .setPort(port) + .build(); + server.start(); + + int code = doGet("/jmx"); + assertEquals(200, code); + assertEquals("Filter must not be installed when useCustomAuthFilter is off", + 0, RecordingFilter.callCount.get()); + } + + // ---- helpers ------------------------------------------------------------- + + /** + * Picks a currently-free port. HttpServer's PortHandlerWrapper keys handlers + * by the configured port, so we cannot pass 0 (dynamic) — the actual bound + * port would not match the registered handler. There is a small race window + * between closing this socket and Jetty binding, which is acceptable for a + * unit test. + */ + private static int freePort() throws IOException { + try (ServerSocket s = new ServerSocket(0)) { + return s.getLocalPort(); + } + } + + private int doGet(String path) throws IOException { + URL url = new URL("http://localhost:" + server.getPort() + path); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + try { + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5_000); + conn.setReadTimeout(5_000); + return conn.getResponseCode(); + } finally { + conn.disconnect(); + } + } + + // ---- sample filters ------------------------------------------------------ + + /** Records every invocation and the init parameters seen at startup. */ + public static class RecordingFilter implements Filter { + static final AtomicInteger callCount = new AtomicInteger(0); + static volatile Map initParams = new HashMap<>(); + + static void reset() { + callCount.set(0); + initParams = new HashMap<>(); + } + + @Override + public void init(FilterConfig fc) { + Map seen = new HashMap<>(); + Enumeration names = fc.getInitParameterNames(); + while (names.hasMoreElements()) { + String n = names.nextElement(); + seen.put(n, fc.getInitParameter(n)); + } + initParams = seen; + } + + @Override + public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) + throws IOException, ServletException { + callCount.incrementAndGet(); + chain.doFilter(req, resp); + } + + @Override + public void destroy() { + } + } + + /** Short-circuits every request with 401, like a deny-by-default auth filter. */ + public static class BlockingFilter implements Filter { + static final AtomicInteger callCount = new AtomicInteger(0); + + static void reset() { + callCount.set(0); + } + + @Override + public void init(FilterConfig fc) { + } + + @Override + public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) + throws IOException, ServletException { + callCount.incrementAndGet(); + ((HttpServletResponse) resp).sendError(HttpServletResponse.SC_UNAUTHORIZED, "blocked"); + } + + @Override + public void destroy() { + } + } +} diff --git a/common/src/test/resources/hive-webapps/test/index.html b/common/src/test/resources/hive-webapps/test/index.html new file mode 100644 index 000000000000..8919831f0e6f --- /dev/null +++ b/common/src/test/resources/hive-webapps/test/index.html @@ -0,0 +1 @@ +test webapp root diff --git a/service/src/java/org/apache/hive/service/server/HiveServer2.java b/service/src/java/org/apache/hive/service/server/HiveServer2.java index 5f7f071d765f..f5f64843f86f 100644 --- a/service/src/java/org/apache/hive/service/server/HiveServer2.java +++ b/service/src/java/org/apache/hive/service/server/HiveServer2.java @@ -465,7 +465,8 @@ private void addHAContextAttributes(HttpServer.Builder builder, HiveConf hiveCon builder.setContextAttribute("hs2.failover.callback", new FailoverHandlerCallback(hs2HARegistry)); } - private static HttpServer.Builder createHttpServerBuilder(String webHost, int port, String name, String contextPath, + @VisibleForTesting + static HttpServer.Builder createHttpServerBuilder(String webHost, int port, String name, String contextPath, HiveConf hiveConf, CLIService cliService, PamAuthenticator pamAuthenticator) throws IOException { HttpServer.Builder builder = new HttpServer.Builder(name); builder.setConf(hiveConf); @@ -542,6 +543,20 @@ private static HttpServer.Builder createHttpServerBuilder(String webHost, int po throw new IllegalArgumentException(ConfVars.HIVE_SERVER2_WEBUI_USE_SSL.varname + " has false value. It is recommended to set to true when PAM is used."); } } + if (hiveConf.getBoolVar(ConfVars.HIVE_SERVER2_WEBUI_USE_CUSTOM_AUTH_FILTER)) { + String authFilter = hiveConf.getVar(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER); + if (authFilter == null || authFilter.isEmpty()) { + throw new IllegalArgumentException(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER.varname + + " is not configured. It is required when Custom Auth Filter is used."); + } + String paramPrefix = ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER.varname + ".param."; + Map params = hiveConf.getPropsWithPrefix(paramPrefix); + + builder.setUseCustomAuthFilter(true); + builder.setCustomAuthFilter(authFilter); + builder.setCustomAuthFilterParams(params); + LOG.info("WebUI will use Custom Auth Filter: {} params: {}", authFilter, params); + } return builder; } diff --git a/service/src/test/org/apache/hive/service/server/TestHiveServer2.java b/service/src/test/org/apache/hive/service/server/TestHiveServer2.java index 42dbdb887957..80492d9479db 100644 --- a/service/src/test/org/apache/hive/service/server/TestHiveServer2.java +++ b/service/src/test/org/apache/hive/service/server/TestHiveServer2.java @@ -20,10 +20,22 @@ import org.apache.hadoop.hive.conf.Constants; import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.hadoop.hive.conf.HiveConf.ConfVars; import org.apache.hadoop.hive.metastore.conf.MetastoreConf; +import org.apache.hive.http.HttpServer; +import org.apache.hive.service.cli.CLIService; +import org.apache.hive.service.cli.session.SessionManager; import org.junit.Test; + import java.util.Map; + import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class TestHiveServer2 { @@ -98,4 +110,72 @@ public void testMaybeStartCompactorThreadsMultipleCustomPoolsAndDefaultPool() { assertEquals(Integer.valueOf(5), startedWorkers.get("pool3")); assertEquals(Integer.valueOf(3), startedWorkers.get(Constants.COMPACTION_DEFAULT_POOL)); } + + // ---- WebUI custom auth filter wiring (createHttpServerBuilder) ---------- + + /** + * Default config: custom auth filter is off, builder must not carry any + * filter wiring. + */ + @Test + public void testCustomAuthFilterDisabledByDefault() throws Exception { + HiveConf conf = new HiveConf(); + HttpServer.Builder builder = invokeCreateHttpServerBuilder(conf); + + assertFalse("useCustomAuthFilter should default to false", + builder.useCustomAuthFilter); + } + + /** + * When the ConfVars are set, createHttpServerBuilder must thread the filter + * class name and every {@code ...custom.auth.filter.param.} key into + * the Builder (the param key is stored without the prefix). + */ + @Test + public void testCustomAuthFilterWiredFromConfig() throws Exception { + HiveConf conf = new HiveConf(); + conf.setBoolVar(ConfVars.HIVE_SERVER2_WEBUI_USE_CUSTOM_AUTH_FILTER, true); + conf.setVar(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER, "com.example.MyAuthFilter"); + conf.set(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER.varname + ".param.realm", "hive"); + conf.set(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER.varname + ".param.ttl", "600"); + + HttpServer.Builder builder = invokeCreateHttpServerBuilder(conf); + + assertTrue("useCustomAuthFilter should be true", + builder.useCustomAuthFilter); + assertEquals("com.example.MyAuthFilter", + builder.customAuthFilter); + + @SuppressWarnings("unchecked") + Map params = builder.customAuthFilterParams; + assertNotNull("customAuthFilterParams must be populated", params); + assertEquals("hive", params.get("realm")); + assertEquals("600", params.get("ttl")); + } + + /** + * Enabling the filter without providing a class name is a configuration + * error and must fail fast at builder construction. + */ + @Test + public void testCustomAuthFilterRejectsEmptyClassName() throws Exception { + HiveConf conf = new HiveConf(); + conf.setBoolVar(ConfVars.HIVE_SERVER2_WEBUI_USE_CUSTOM_AUTH_FILTER, true); + conf.setVar(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER, ""); + + try { + invokeCreateHttpServerBuilder(conf); + fail("Expected IllegalArgumentException when custom auth filter class name is empty"); + } catch (IllegalArgumentException expected) { + assertTrue("Exception message should reference the custom auth filter ConfVar", + expected.getMessage().contains(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER.varname)); + } + } + + private static HttpServer.Builder invokeCreateHttpServerBuilder(HiveConf conf) throws Exception { + CLIService cli = mock(CLIService.class); + SessionManager sm = mock(SessionManager.class); + when(cli.getSessionManager()).thenReturn(sm); + return HiveServer2.createHttpServerBuilder("localhost", 0, "test", "/", conf, cli, null); + } }