mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-07-20 15:02:27 +00:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user