From e6f35f4340f2363ec112a422b39479dde6679cfb Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Mon, 13 Apr 2026 16:41:06 +0800 Subject: [PATCH] Added support for client certificate forwarding --- .../net/woggioni/rbcs/api/Configuration.java | 7 ++ .../rbcs/server/RemoteBuildCacheServer.kt | 70 +++++++++++++++++++ .../rbcs/server/auth/Authenticator.kt | 15 +++- .../rbcs/server/configuration/Parser.kt | 23 ++++++ .../rbcs/server/configuration/Serializer.kt | 17 +++++ .../rbcs/server/schema/rbcs-server.xsd | 48 +++++++++++++ 6 files changed, 179 insertions(+), 1 deletion(-) diff --git a/rbcs-api/src/main/java/net/woggioni/rbcs/api/Configuration.java b/rbcs-api/src/main/java/net/woggioni/rbcs/api/Configuration.java index a9c8e8a..0fe1e73 100644 --- a/rbcs-api/src/main/java/net/woggioni/rbcs/api/Configuration.java +++ b/rbcs-api/src/main/java/net/woggioni/rbcs/api/Configuration.java @@ -136,6 +136,13 @@ public class Configuration { TlsCertificateExtractor groupExtractor; } + @Value + public static class ForwardedClientCertificateAuthentication implements Authentication { + String headerName; + TlsCertificateExtractor userExtractor; + TlsCertificateExtractor groupExtractor; + } + public interface Cache { CacheHandlerFactory materialize(); String getNamespaceURI(); diff --git a/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/RemoteBuildCacheServer.kt b/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/RemoteBuildCacheServer.kt index e9c095a..e3d76f0 100644 --- a/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/RemoteBuildCacheServer.kt +++ b/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/RemoteBuildCacheServer.kt @@ -158,6 +158,59 @@ class RemoteBuildCacheServer(private val cfg: Configuration) { } } + @Sharable + private class ForwardedClientCertificateAuthenticator( + authorizer: Authorizer, + private val anonymousUserGroups: Set?, + private val subjectDnUserExtractor: SubjectDnExtractor?, + private val subjectDnGroupExtractor: SubjectDnExtractor?, + private val headerName: String, + private val trustedProxyIPs: List, + private val users: Map, + private val groups: Map, + ) : AbstractNettyHttpAuthenticator(authorizer) { + + companion object { + private val log = createLogger() + } + + override fun authenticate(ctx: ChannelHandlerContext, req: HttpRequest): AuthenticationResult? { + val clientIp = ctx.channel().attr(clientIp).get() + if (clientIp == null || trustedProxyIPs.none { it.contains(clientIp.address) }) { + log.debug(ctx) { + "Rejecting forwarded client certificate authentication from untrusted address: $clientIp" + } + return null + } + val subjectDn = req.headers()[headerName] + ?: return anonymousUserGroups?.let { AuthenticationResult(null, it) } + val ldapName = try { + LdapName(subjectDn) + } catch (e: Exception) { + log.debug(ctx) { + "Invalid subject DN in header $headerName: $subjectDn" + } + return anonymousUserGroups?.let { AuthenticationResult(null, it) } + } + val user = subjectDnUserExtractor?.extract(ldapName)?.let { userName -> + users[userName] ?: throw RuntimeException("Failed to extract user '$userName'") + } + val group = subjectDnGroupExtractor?.extract(ldapName)?.let { groupName -> + groups[groupName] ?: throw RuntimeException("Failed to extract group '$groupName'") + } + val allGroups = ((user?.groups ?: emptySet()).asSequence() + sequenceOf(group).filterNotNull()).toSet() + return AuthenticationResult(user, allGroups) + } + } + + private data class SubjectDnExtractor(val rdnType: String, val pattern: Pattern) { + fun extract(ldapName: LdapName): String? { + return ldapName.rdns.find { it.type == rdnType } + ?.let { pattern.matcher(it.value.toString()) } + ?.takeIf(Matcher::matches)?.group(1) + } + } + @Sharable private class NettyHttpBasicAuthenticator( private val users: Map, authorizer: Authorizer @@ -264,6 +317,23 @@ class RemoteBuildCacheServer(private val cfg: Configuration) { ) } + is Configuration.ForwardedClientCertificateAuthentication -> { + ForwardedClientCertificateAuthenticator( + RoleAuthorizer(), + cfg.users[""]?.groups, + auth.userExtractor?.let { extractor -> + SubjectDnExtractor(extractor.rdnType, Pattern.compile(extractor.pattern)) + }, + auth.groupExtractor?.let { extractor -> + SubjectDnExtractor(extractor.rdnType, Pattern.compile(extractor.pattern)) + }, + auth.headerName, + cfg.trustedProxyIPs, + cfg.users, + cfg.groups, + ) + } + else -> null } diff --git a/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/auth/Authenticator.kt b/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/auth/Authenticator.kt index 0f9b6da..202551e 100644 --- a/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/auth/Authenticator.kt +++ b/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/auth/Authenticator.kt @@ -12,15 +12,18 @@ import io.netty.handler.codec.http.HttpRequest import io.netty.handler.codec.http.HttpResponseStatus import io.netty.handler.codec.http.HttpVersion import io.netty.util.ReferenceCountUtil +import java.net.InetSocketAddress import net.woggioni.rbcs.api.Configuration import net.woggioni.rbcs.api.Configuration.Group import net.woggioni.rbcs.api.Role +import net.woggioni.rbcs.common.createLogger import net.woggioni.rbcs.server.RemoteBuildCacheServer - abstract class AbstractNettyHttpAuthenticator(private val authorizer: Authorizer) : ChannelInboundHandlerAdapter() { companion object { + private val log = createLogger() + private val AUTHENTICATION_FAILED: FullHttpResponse = DefaultFullHttpResponse( HttpVersion.HTTP_1_1, HttpResponseStatus.UNAUTHORIZED, Unpooled.EMPTY_BUFFER ).apply { @@ -53,6 +56,16 @@ abstract class AbstractNettyHttpAuthenticator(private val authorizer: Authorizer result.groups.asSequence().flatMap { it.roles.asSequence() } ).toSet() val authorized = authorizer.authorize(roles, msg) + if(log.isDebugEnabled) { + val authorizedMessage = if(authorized) { "Authorized" } else { "Forbidden" } + val clientAddress = ctx.channel().attr(RemoteBuildCacheServer.clientIp).get() + val roleString = "[" + roles.asSequence().map { "\"" + it + "\""}.joinToString(", ") + "]" + result.user?.name?.takeUnless(String::isEmpty)?.let { username -> + log.debug("$authorizedMessage ${msg.method()} request from user $username with address $clientAddress, granted roles $roleString") + } ?: { + log.debug("$authorizedMessage anonymous ${msg.method()} request with address $clientAddress, granted roles $roleString") + } + } if (authorized) { super.channelRead(ctx, msg) } else { diff --git a/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/configuration/Parser.kt b/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/configuration/Parser.kt index 1e475c1..f1ac5c6 100644 --- a/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/configuration/Parser.kt +++ b/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/configuration/Parser.kt @@ -8,6 +8,7 @@ import net.woggioni.rbcs.api.Configuration.Authentication import net.woggioni.rbcs.api.Configuration.BasicAuthentication import net.woggioni.rbcs.api.Configuration.Cache import net.woggioni.rbcs.api.Configuration.ClientCertificateAuthentication +import net.woggioni.rbcs.api.Configuration.ForwardedClientCertificateAuthentication import net.woggioni.rbcs.api.Configuration.Group import net.woggioni.rbcs.api.Configuration.KeyStore import net.woggioni.rbcs.api.Configuration.Tls @@ -77,6 +78,28 @@ object Parser { } authentication = ClientCertificateAuthentication(tlsExtractorUser, tlsExtractorGroup) } + + "forwarded-client-certificate" -> { + val headerName = gchild.renderAttribute("header-name") ?: "X-Client-Cert-Subject-DN" + var tlsExtractorUser: TlsCertificateExtractor? = null + var tlsExtractorGroup: TlsCertificateExtractor? = null + for (ggchild in gchild.asIterable()) { + when (ggchild.localName) { + "group-extractor" -> { + val attrName = ggchild.renderAttribute("attribute-name") + val pattern = ggchild.renderAttribute("pattern") + tlsExtractorGroup = TlsCertificateExtractor(attrName, pattern) + } + + "user-extractor" -> { + val attrName = ggchild.renderAttribute("attribute-name") + val pattern = ggchild.renderAttribute("pattern") + tlsExtractorUser = TlsCertificateExtractor(attrName, pattern) + } + } + } + authentication = ForwardedClientCertificateAuthentication(headerName, tlsExtractorUser, tlsExtractorGroup) + } } } } diff --git a/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/configuration/Serializer.kt b/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/configuration/Serializer.kt index d383886..67e78d3 100644 --- a/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/configuration/Serializer.kt +++ b/rbcs-server/src/main/kotlin/net/woggioni/rbcs/server/configuration/Serializer.kt @@ -165,6 +165,23 @@ object Serializer { } } } + is Configuration.ForwardedClientCertificateAuthentication -> { + node("forwarded-client-certificate") { + attr("header-name", authentication.headerName) + authentication.groupExtractor?.let { extractor -> + node("group-extractor") { + attr("attribute-name", extractor.rdnType) + attr("pattern", extractor.pattern) + } + } + authentication.userExtractor?.let { extractor -> + node("user-extractor") { + attr("attribute-name", extractor.rdnType) + attr("pattern", extractor.pattern) + } + } + } + } } } } diff --git a/rbcs-server/src/main/resources/net/woggioni/rbcs/server/schema/rbcs-server.xsd b/rbcs-server/src/main/resources/net/woggioni/rbcs/server/schema/rbcs-server.xsd index 6878ce9..65aa616 100644 --- a/rbcs-server/src/main/resources/net/woggioni/rbcs/server/schema/rbcs-server.xsd +++ b/rbcs-server/src/main/resources/net/woggioni/rbcs/server/schema/rbcs-server.xsd @@ -311,6 +311,45 @@ + + + + Authenticate clients based on a custom HTTP header containing the client TLS certificate + subject DN, forwarded by a reverse proxy that performs TLS termination. The proxy must be + listed in the trusted-proxies configuration for the header to be accepted. + + + + + + + A regex based extractor that will be used to determine which group the client belongs to, + based on the X.500 name of the subject DN forwarded by the reverse proxy. + When this is set RBAC works even if the user isn't listed in the <users/> section as + the client will be assigned role solely based on the group he is found to belong to. + Note that this does not allow for a client to be part of multiple groups. + + + + + + + A regex based extractor that will be used to assign a user to a connected client, + based on the X.500 name of the subject DN forwarded by the reverse proxy. + + + + + + + + Name of the HTTP header containing the client certificate subject DN + forwarded by the reverse proxy. Defaults to "X-Client-Cert-Subject-DN". + + + + + @@ -380,6 +419,15 @@ + + + + Enable forwarded client certificate authentication. Authenticates clients based on + a custom HTTP header containing the client certificate subject DN, forwarded by a + reverse proxy that performs TLS termination. Requires trusted-proxies to be configured. + + +