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.
This commit is contained in:
Chris Lu
2026-07-07 20:52:50 -07:00
committed by GitHub
parent d35c4b3d2d
commit bcc528a517
6 changed files with 187 additions and 0 deletions
@@ -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<String> AUTHORIZATION_KEY =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
private final String headerValue;
public BasicAuthInterceptor(String headerValue) {
this.headerValue = headerValue;
}
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(next.newCall(method, callOptions)) {
@Override
public void start(Listener<RespT> responseListener, Metadata headers) {
headers.put(AUTHORIZATION_KEY, headerValue);
super.start(responseListener, headers);
}
};
}
}
@@ -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<String, FilerProto.Locations> 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<ManagedChannel> 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;
}
@@ -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 <base64(user:password)>" 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) {
@@ -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);
@@ -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()));
@@ -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 <ReqT, RespT> ClientCall<ReqT, RespT> newCall(
MethodDescriptor<ReqT, RespT> method, CallOptions callOptions) {
return new ClientCall<ReqT, RespT>() {
@Override
public void start(Listener<RespT> 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<Object, Object> call =
interceptor.interceptCall(null, CallOptions.DEFAULT, channel);
call.start(null, new Metadata());
Metadata.Key<String> key = Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
assertNotNull(captured[0]);
assertEquals(headerValue, captured[0].get(key));
}
}