Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:

env:
WILDFLY_VERSION: 39.0.1.Final
TEST_CLASS: StickySessionTest,SslFailoverTest,SslCrlTest
TEST_CLASS: StickySessionTest,SslFailoverTest,SslCrlTest,AjpAuthPropagationTest
MOD_PROXY_CLUSTER_REPO: https://github.com/modcluster/mod_proxy_cluster.git

jobs:
Expand Down Expand Up @@ -44,12 +44,12 @@ jobs:
- name: Run tests (Docker)
if: runner.os != 'Windows'
shell: bash
run: mvn -B test -Dtest=${{ env.TEST_CLASS }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
run: mvn -B test -Dtest=${{ env.TEST_CLASS }} -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}

- name: Run tests (Native)
if: runner.os == 'Windows'
shell: bash
run: mvn -B test -Pnative -Dtest=${{ env.TEST_CLASS }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
run: mvn -B test -Pnative -Dtest=${{ env.TEST_CLASS }} -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}

- name: Publish test results
uses: mikepenz/action-junit-report@v6
Expand Down Expand Up @@ -100,8 +100,7 @@ jobs:
- name: Run tests
run: |
mvn -B test -Pnative \
-Dtest=${{ env.TEST_CLASS }} \
-DexcludedGroups=none \
-Dtest=${{ env.TEST_CLASS }},AjpAuthPropagationTest \
-Dbalancer.type=httpd \
-Dhttpd.home=/usr \
-Dhttpd.modules.path=${{ github.workspace }}/target/mod_proxy_cluster/native/build/modules \
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ src/test/java/org/jboss/modcluster/test/
│ └── LoadMetricsTest.java
├── session/ # Session management tests
│ └── SessionManagementTest.java
├── auth/ # Authentication propagation tests
│ ├── AjpAuthConfigurator.java
│ └── AjpAuthPropagationTest.java
├── ssl/ # SSL/TLS tests
│ ├── SslCrlTest.java
│ ├── SslFailoverTest.java
Expand Down Expand Up @@ -289,6 +292,9 @@ String result = worker.executeCli("/subsystem=modcluster:read-resource");
- **SslFailoverTest** - SSL with failover scenarios
- **SslWorkerAuthenticationTest** - Mutual SSL authentication

### Authentication Tests
- **AjpAuthPropagationTest** - REMOTE_USER propagation via AJP (Elytron EXTERNAL mechanism, Docker and native)

### Load Balancing Tests
- **LoadBalancingGroupFailoverTest** - Load distribution and group failover
- **LoadMetricsTest** - Load metrics calculation and custom metrics
Expand Down Expand Up @@ -325,6 +331,7 @@ This test suite aims for feature parity with `noe-tests/modcluster` (64 test fil
| Advanced Failover | Yes | AdvancedFailoverTest, FailoverSettingsTest |
| Load Balancing | Yes | LoadBalancingGroupFailoverTest, LoadMetricsTest |
| SSL/TLS | Yes | SslCrlTest, SslFailoverTest, SslWorkerAuthenticationTest |
| Authentication (AJP) | Yes | AjpAuthPropagationTest |
| Dynamic Reconfiguration | Yes | DynamicReconfTest, SettingsTest |
| Context Lifecycle | Yes | ContextLifecycleTest |
| Session Management | Yes | SessionManagementTest |
Expand All @@ -338,7 +345,7 @@ This test suite aims for feature parity with `noe-tests/modcluster` (64 test fil

| Area | noe-tests Reference |
|------|-------------------|
| AJP Protocol | ModClusterAJP.groovy |
| AJP Protocol (beyond auth) | ModClusterAJP.groovy |
| mod_proxy / mod_rewrite | ModProxyTest.groovy, ModRewriteTest.groovy |
| Bug-specific regressions | JBCS*, JBQA* test files |

Expand Down
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@
</activation>
<properties>
<balancer.type>undertow</balancer.type>
<test.excluded.groups.balancer>httpd</test.excluded.groups.balancer>
</properties>
</profile>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.jboss.modcluster.test.apps;

import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.exporter.ZipExporter;
import org.jboss.shrinkwrap.api.spec.WebArchive;

import java.io.File;
import java.net.URL;

/**
* Builder for creating the secured WAR application at runtime using ShrinkWrap.
* Packages {@link SecuredServlet} with a {@code web.xml} that declares the EXTERNAL
* auth method and a {@code jboss-web.xml} that maps to the {@code ajp-auth-domain}
* application security domain.
*/
public class SecuredAppBuilder {

/**
* Creates the secured.war file.
*
* @return File reference to generated WAR in temp directory
*/
public static File createSecuredApp() {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
URL webXml = cl.getResource("apps/secured/web.xml");
URL jbossWebXml = cl.getResource("apps/secured/jboss-web.xml");

final WebArchive war = ShrinkWrap.create(WebArchive.class, "secured.war")
.addClass(SecuredServlet.class)
.setWebXML(webXml)
.addAsWebInfResource(jbossWebXml, "jboss-web.xml");

final File tempWar = new File(System.getProperty("java.io.tmpdir"), "secured.war");
war.as(ZipExporter.class).exportTo(tempWar, true);
tempWar.deleteOnExit();

return tempWar;
}
}
28 changes: 28 additions & 0 deletions src/test/java/org/jboss/modcluster/test/apps/SecuredServlet.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.jboss.modcluster.test.apps;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.io.PrintWriter;

/**
* Servlet secured by the EXTERNAL authentication mechanism.
* Returns the authenticated user's name and the worker node name.
* Security constraints are declared in {@code web.xml} with
* {@code <auth-method>EXTERNAL</auth-method>}.
*/
@WebServlet("/secured")
public class SecuredServlet extends HttpServlet {

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("text/plain");
PrintWriter out = resp.getWriter();
out.println("user=" + req.getRemoteUser());
out.println("worker=" + System.getProperty("jboss.node.name", "unknown"));
}
}
180 changes: 180 additions & 0 deletions src/test/java/org/jboss/modcluster/test/auth/AjpAuthConfigurator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package org.jboss.modcluster.test.auth;

import org.jboss.dmr.ModelNode;
import org.jboss.modcluster.test.utils.CommandResult;
import org.jboss.modcluster.test.utils.WildFlyWorker;
import org.jboss.modcluster.test.utils.balancer.Balancer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.wildfly.extras.creaper.core.online.ModelNodeResult;
import org.wildfly.extras.creaper.core.online.operations.Address;
import org.wildfly.extras.creaper.core.online.operations.Operations;
import org.wildfly.extras.creaper.core.online.operations.Values;

import java.nio.file.Files;
import java.nio.file.Path;

/**
* Configures Elytron EXTERNAL mechanism authentication on WildFly workers
* and httpd REMOTE_USER injection on the balancer.
*
* <p>This enables end-to-end testing of AJP authentication propagation:
* httpd authenticates the user via {@code mod_auth_basic} → sets {@code REMOTE_USER}
* → {@code mod_proxy_ajp} forwards it as the AJP REMOTE_USER attribute → Undertow
* receives it → Elytron's EXTERNAL mechanism authenticates the user.</p>
*/
public class AjpAuthConfigurator {

private static final Logger log = LoggerFactory.getLogger(AjpAuthConfigurator.class);

private static final String REALM_NAME = "ajp-auth-realm";
private static final String ROLE_DECODER_NAME = "ajp-role-decoder";
private static final String SECURITY_DOMAIN_NAME = "ajp-auth-sd";
private static final String AUTH_FACTORY_NAME = "ajp-auth-factory";
private static final String APP_SECURITY_DOMAIN = "ajp-auth-domain";

/**
* Configure Elytron on a worker for EXTERNAL mechanism authentication.
* Creates a filesystem-realm with the given users, wires up the security domain
* and http-authentication-factory, and links it to Undertow.
*
* @param worker the WildFly worker to configure
* @param users user entries (username → role)
*/
public void configureWorker(WildFlyWorker worker, UserEntry... users) throws Exception {
Operations ops = worker.getOperations();

Address realmAddr = Address.subsystem("elytron").and("filesystem-realm", REALM_NAME);
if (!ops.exists(realmAddr)) {
ops.add(realmAddr, Values.of("path", "ajp-auth-users")
.and("relative-to", "jboss.server.config.dir")).assertSuccess();
}

for (UserEntry user : users) {
ModelNodeResult result = ops.invoke("add-identity", realmAddr,
Values.of("identity", user.username));
if (result.isSuccess()) {
ops.invoke("add-identity-attribute", realmAddr,
Values.of("identity", user.username)
.and("name", "Roles")
.andList("value", user.role)).assertSuccess();
log.info("Added user '{}' with role '{}' to realm", user.username, user.role);
} else {
log.info("User '{}' already exists in realm, skipping", user.username);
}
}

Address decoderAddr = Address.subsystem("elytron").and("simple-role-decoder", ROLE_DECODER_NAME);
if (!ops.exists(decoderAddr)) {
ops.add(decoderAddr, Values.of("attribute", "Roles")).assertSuccess();
}

Address domainAddr = Address.subsystem("elytron").and("security-domain", SECURITY_DOMAIN_NAME);
if (!ops.exists(domainAddr)) {
ModelNode realmEntry = new ModelNode();
realmEntry.get("realm").set(REALM_NAME);
realmEntry.get("role-decoder").set(ROLE_DECODER_NAME);

ops.add(domainAddr, Values.of("default-realm", REALM_NAME)
.and("permission-mapper", "default-permission-mapper")
.andList("realms", realmEntry)).assertSuccess();
}

Address factoryAddr = Address.subsystem("elytron")
.and("http-authentication-factory", AUTH_FACTORY_NAME);
if (!ops.exists(factoryAddr)) {
ModelNode mechanismEntry = new ModelNode();
mechanismEntry.get("mechanism-name").set("EXTERNAL");

ops.add(factoryAddr, Values.of("security-domain", SECURITY_DOMAIN_NAME)
.and("http-server-mechanism-factory", "global")
.andList("mechanism-configurations", mechanismEntry)).assertSuccess();
}

Address appSecDomain = Address.subsystem("undertow")
.and("application-security-domain", APP_SECURITY_DOMAIN);
if (!ops.exists(appSecDomain)) {
ops.add(appSecDomain, Values.of("http-authentication-factory", AUTH_FACTORY_NAME)).assertSuccess();
}

worker.reload();
log.info("Elytron EXTERNAL mechanism configured on worker '{}'", worker.getName());
}

/**
* Configure httpd to proxy requests to the secured app via mod_proxy_ajp
* with REMOTE_USER set via Basic authentication. This uses a direct
* {@code ProxyPass} to the worker's AJP port.
*
* <p>When {@code username} is non-null, httpd Basic auth is configured with
* an htpasswd file. After authenticating the user, httpd sets the AJP protocol's
* {@code remote_user} attribute, which Undertow's AJP listener forwards to
* Elytron's EXTERNAL mechanism. This is the same AJP attribute path that
* IIS/isapi_redirect uses after Windows authentication.</p>
*
* @param balancer the httpd balancer
* @param username the username to inject as REMOTE_USER
* @param ajpPort the worker's AJP listener port
*/
public void configureBalancerRemoteUser(Balancer balancer, String username, String ajpHost, int ajpPort)
throws Exception {
StringBuilder conf = new StringBuilder();
conf.append("<IfModule !proxy_module>\n");
conf.append(" LoadModule proxy_module modules/mod_proxy.so\n");
conf.append("</IfModule>\n");
conf.append("<IfModule !proxy_ajp_module>\n");
conf.append(" LoadModule proxy_ajp_module modules/mod_proxy_ajp.so\n");
conf.append("</IfModule>\n");
conf.append("<IfModule !authn_file_module>\n");
conf.append(" LoadModule authn_file_module modules/mod_authn_file.so\n");
conf.append("</IfModule>\n");
conf.append("<IfModule !authn_core_module>\n");
conf.append(" LoadModule authn_core_module modules/mod_authn_core.so\n");
conf.append("</IfModule>\n");
conf.append("<IfModule !authz_user_module>\n");
conf.append(" LoadModule authz_user_module modules/mod_authz_user.so\n");
conf.append("</IfModule>\n");
conf.append("<IfModule !auth_basic_module>\n");
conf.append(" LoadModule auth_basic_module modules/mod_auth_basic.so\n");
conf.append("</IfModule>\n\n");

conf.append("ProxyPass /secured/ ajp://").append(ajpHost).append(":").append(ajpPort).append("/secured/\n");
conf.append("ProxyPassReverse /secured/ ajp://").append(ajpHost).append(":").append(ajpPort).append("/secured/\n\n");

if (username != null) {
String htpasswdPath = balancer.getServerHome() + "/conf/test-users.htpasswd";
conf.append("<Location /secured>\n");
conf.append(" AuthType Basic\n");
conf.append(" AuthName \"Test\"\n");
conf.append(" AuthBasicProvider file\n");
conf.append(" AuthUserFile \"").append(htpasswdPath).append("\"\n");
conf.append(" Require valid-user\n");
conf.append("</Location>\n");

CommandResult result = balancer.execCommand("htpasswd", "-cb",
htpasswdPath, username, "password");
if (!result.isSuccess()) {
balancer.execCommand(balancer.getServerHome() + "/bin/htpasswd",
"-cb", htpasswdPath, username, "password");
}
}

Path tempConf = Files.createTempFile("ajp-auth", ".conf");
Files.writeString(tempConf, conf.toString());
balancer.copyLocalFile(tempConf, balancer.getConfDir() + "/extra/ajp-auth.conf");

balancer.reload();
log.info("Configured direct AJP proxy to port {} with REMOTE_USER='{}'", ajpPort, username);
}

/** A username-to-role mapping for the Elytron filesystem realm. */
public static class UserEntry {
final String username;
final String role;

public UserEntry(String username, String role) {
this.username = username;
this.role = role;
}
}
}
Loading
Loading