From bcc528a5175d848c246d48e1dab6310a2b54b021 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 7 Jul 2026 20:52:50 -0700 Subject: [PATCH] java: HTTP Basic Auth for filer behind an Nginx reverse proxy (#10258) * java: HTTP Basic Auth for filer behind an Nginx reverse proxy Attach an "Authorization: Basic" header to the filer gRPC channel through a ClientInterceptor and to the volume read/write HTTP requests, so the Java client can reach SeaweedFS fronted by Nginx with auth_basic enabled. Credentials come from a [basic_auth] section in security.toml or from FilerSecurityContext.setBasicAuth(). On writes the volume JWT still owns the Authorization header when present, since the two cannot share it. * java: precompute Basic Auth header and warn on plaintext channel Cache the Base64 Authorization value once instead of re-encoding on every chunk read/write, and log a one-time warning when Basic Auth rides a plaintext gRPC channel where the credentials would travel in cleartext. --- .../client/BasicAuthInterceptor.java | 35 +++++++ .../seaweedfs/client/FilerGrpcClient.java | 11 +++ .../client/FilerSecurityContext.java | 34 +++++++ .../java/seaweedfs/client/SeaweedRead.java | 5 + .../java/seaweedfs/client/SeaweedWrite.java | 7 ++ .../java/seaweedfs/client/BasicAuthTest.java | 95 +++++++++++++++++++ 6 files changed, 187 insertions(+) create mode 100644 other/java/client/src/main/java/seaweedfs/client/BasicAuthInterceptor.java create mode 100644 other/java/client/src/test/java/seaweedfs/client/BasicAuthTest.java diff --git a/other/java/client/src/main/java/seaweedfs/client/BasicAuthInterceptor.java b/other/java/client/src/main/java/seaweedfs/client/BasicAuthInterceptor.java new file mode 100644 index 000000000..8722f25b9 --- /dev/null +++ b/other/java/client/src/main/java/seaweedfs/client/BasicAuthInterceptor.java @@ -0,0 +1,35 @@ +package seaweedfs.client; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ForwardingClientCall; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; + +// Adds an HTTP Basic Auth "Authorization" header to every gRPC call so the filer can be +// reached through an Nginx reverse proxy with auth_basic enabled. +public class BasicAuthInterceptor implements ClientInterceptor { + + private static final Metadata.Key AUTHORIZATION_KEY = + Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER); + + private final String headerValue; + + public BasicAuthInterceptor(String headerValue) { + this.headerValue = headerValue; + } + + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new ForwardingClientCall.SimpleForwardingClientCall(next.newCall(method, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + headers.put(AUTHORIZATION_KEY, headerValue); + super.start(responseListener, headers); + } + }; + } +} diff --git a/other/java/client/src/main/java/seaweedfs/client/FilerGrpcClient.java b/other/java/client/src/main/java/seaweedfs/client/FilerGrpcClient.java index e559431dd..7a06bb526 100644 --- a/other/java/client/src/main/java/seaweedfs/client/FilerGrpcClient.java +++ b/other/java/client/src/main/java/seaweedfs/client/FilerGrpcClient.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.Random; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; public class FilerGrpcClient { @@ -37,6 +38,8 @@ public class FilerGrpcClient { public final Map vidLocations = new HashMap<>(); protected int randomClientId; + private static final AtomicBoolean plaintextBasicAuthWarned = new AtomicBoolean(false); + // Connection pool to handle concurrent requests private static final int CHANNEL_POOL_SIZE = 4; private final List channelPool; @@ -99,6 +102,14 @@ public class FilerGrpcClient { builder.overrideAuthority(cn); } } + + String basicAuthHeader = FilerSecurityContext.getBasicAuthHeaderValue(); + if (basicAuthHeader != null) { + if (sslContext == null && plaintextBasicAuthWarned.compareAndSet(false, true)) { + logger.warn("Basic Auth is enabled on a plaintext gRPC channel; credentials are sent in cleartext. Use TLS to the proxy."); + } + builder.intercept(new BasicAuthInterceptor(basicAuthHeader)); + } return builder; } diff --git a/other/java/client/src/main/java/seaweedfs/client/FilerSecurityContext.java b/other/java/client/src/main/java/seaweedfs/client/FilerSecurityContext.java index 07a2ce286..64a6f13df 100644 --- a/other/java/client/src/main/java/seaweedfs/client/FilerSecurityContext.java +++ b/other/java/client/src/main/java/seaweedfs/client/FilerSecurityContext.java @@ -13,10 +13,12 @@ import org.slf4j.LoggerFactory; import javax.net.ssl.*; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.PrivateKey; import java.security.cert.X509Certificate; +import java.util.Base64; public abstract class FilerSecurityContext extends SslContext { //extends Netty SslContext to access its protected static utility methods in @@ -36,6 +38,11 @@ public abstract class FilerSecurityContext extends SslContext { private static String httpClientCertChainFilePath; private static String httpClientPrivateKeyFilePath; + // Optional HTTP Basic Auth for reaching SeaweedFS through an Nginx reverse proxy + // that has auth_basic enabled. Applied to both the filer gRPC channel and the + // volume/proxy HTTP requests. Precomputed so per-chunk requests don't re-encode. + private static volatile String basicAuthHeaderValue; + static { String securityFileName = "security.toml"; @@ -55,6 +62,9 @@ public abstract class FilerSecurityContext extends SslContext { Toml toml = new Toml().read(securityFile); logger.debug("reading ssl setup from {}", securityFile); + basicAuthHeaderValue = computeBasicAuthHeaderValue( + toml.getString("basic_auth.username"), toml.getString("basic_auth.password")); + grpcTrustCertCollectionFilePath = toml.getString("grpc.ca"); logger.debug("loading gRPC ca from {}", grpcTrustCertCollectionFilePath); grpcClientCertChainFilePath = toml.getString("grpc.client.cert"); @@ -120,6 +130,30 @@ public abstract class FilerSecurityContext extends SslContext { return httpSslContext; } + // Configure HTTP Basic Auth programmatically instead of via security.toml. Must be + // called before constructing a FilerClient so the credentials reach the gRPC channel. + public static void setBasicAuth(String username, String password) { + basicAuthHeaderValue = computeBasicAuthHeaderValue(username, password); + } + + public static boolean isBasicAuthEnabled() { + return basicAuthHeaderValue != null; + } + + // Returns the "Basic " Authorization header value, or null + // when no Basic Auth credentials are configured. + public static String getBasicAuthHeaderValue() { + return basicAuthHeaderValue; + } + + private static String computeBasicAuthHeaderValue(String username, String password) { + if (Strings.isNullOrEmpty(username)) { + return null; + } + String credentials = username + ":" + (password == null ? "" : password); + return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } + private static SslContext buildGrpcSslContext() throws SSLException { SslContextBuilder builder = GrpcSslContexts.forClient(); if (grpcTrustCertCollectionFilePath != null) { diff --git a/other/java/client/src/main/java/seaweedfs/client/SeaweedRead.java b/other/java/client/src/main/java/seaweedfs/client/SeaweedRead.java index 3fd184671..be6e9de91 100644 --- a/other/java/client/src/main/java/seaweedfs/client/SeaweedRead.java +++ b/other/java/client/src/main/java/seaweedfs/client/SeaweedRead.java @@ -161,6 +161,11 @@ public class SeaweedRead { request.setHeader(HttpHeaders.ACCEPT_ENCODING, "gzip"); + String basicAuthHeader = FilerSecurityContext.getBasicAuthHeaderValue(); + if (basicAuthHeader != null) { + request.setHeader(HttpHeaders.AUTHORIZATION, basicAuthHeader); + } + byte[] data = null; CloseableHttpResponse response = SeaweedUtil.getClosableHttpClient().execute(request); diff --git a/other/java/client/src/main/java/seaweedfs/client/SeaweedWrite.java b/other/java/client/src/main/java/seaweedfs/client/SeaweedWrite.java index 0fadd53cc..c56800721 100644 --- a/other/java/client/src/main/java/seaweedfs/client/SeaweedWrite.java +++ b/other/java/client/src/main/java/seaweedfs/client/SeaweedWrite.java @@ -179,7 +179,14 @@ public class SeaweedWrite { HttpPost post = new HttpPost(targetUrl); if (auth != null && auth.length() != 0) { + // Volume JWT and Basic Auth both use the Authorization header; the JWT wins + // when present. Front the volume with a JWT-free path to use Basic Auth here. post.addHeader("Authorization", "BEARER " + auth); + } else { + String basicAuthHeader = FilerSecurityContext.getBasicAuthHeaderValue(); + if (basicAuthHeader != null) { + post.addHeader("Authorization", basicAuthHeader); + } } post.addHeader("Content-MD5", Base64.getEncoder().encodeToString(md.digest())); diff --git a/other/java/client/src/test/java/seaweedfs/client/BasicAuthTest.java b/other/java/client/src/test/java/seaweedfs/client/BasicAuthTest.java new file mode 100644 index 000000000..6240e4210 --- /dev/null +++ b/other/java/client/src/test/java/seaweedfs/client/BasicAuthTest.java @@ -0,0 +1,95 @@ +package seaweedfs.client; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import org.junit.After; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class BasicAuthTest { + + @After + public void clearCredentials() { + FilerSecurityContext.setBasicAuth(null, null); + } + + @Test + public void noCredentialsMeansNoHeader() { + FilerSecurityContext.setBasicAuth(null, null); + assertNull(FilerSecurityContext.getBasicAuthHeaderValue()); + assertFalse(FilerSecurityContext.isBasicAuthEnabled()); + + FilerSecurityContext.setBasicAuth("", "ignored"); + assertNull(FilerSecurityContext.getBasicAuthHeaderValue()); + assertFalse(FilerSecurityContext.isBasicAuthEnabled()); + } + + @Test + public void encodesUserAndPassword() { + FilerSecurityContext.setBasicAuth("alice", "s3cret"); + String expected = "Basic " + Base64.getEncoder() + .encodeToString("alice:s3cret".getBytes(StandardCharsets.UTF_8)); + assertEquals(expected, FilerSecurityContext.getBasicAuthHeaderValue()); + assertTrue(FilerSecurityContext.isBasicAuthEnabled()); + } + + @Test + public void interceptorAddsAuthorizationHeader() { + String headerValue = "Basic dGVzdDp0ZXN0"; + final Metadata[] captured = new Metadata[1]; + + Channel channel = new Channel() { + @Override + public ClientCall newCall( + MethodDescriptor method, CallOptions callOptions) { + return new ClientCall() { + @Override + public void start(Listener responseListener, Metadata headers) { + captured[0] = headers; + } + + @Override + public void request(int numMessages) { + } + + @Override + public void cancel(String message, Throwable cause) { + } + + @Override + public void halfClose() { + } + + @Override + public void sendMessage(ReqT message) { + } + }; + } + + @Override + public String authority() { + return "test"; + } + }; + + BasicAuthInterceptor interceptor = new BasicAuthInterceptor(headerValue); + ClientCall call = + interceptor.interceptCall(null, CallOptions.DEFAULT, channel); + call.start(null, new Metadata()); + + Metadata.Key key = Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER); + assertNotNull(captured[0]); + assertEquals(headerValue, captured[0].get(key)); + } +}