Brute force login protection icon
Module Id
brute-force-login-protection
Group Id
org.jahia.community
Updated
Requires Jahia
8.2.1.0
Author
Florent BOURASSE
Category
Tools and Utilities
Status
COMMUNITY info

Brute force login protection group_work

security cloud

Detects and blocks brute-force login attempts against a Jahia server, in the spirit of fail2ban. Supports IPv4 and IPv6.

Features

  • Sliding-window detection — count failed logins per IP within a configurable time window (findTime), not just consecutive failures.
  • Per-jail configuration — multiple jails, each with their own thresholds and ban durations.
  • Persistent bans with recidive escalation — bans survive restarts; repeat offenders get progressively longer bans, capped by a configurable maximum.
  • Pluggable failure sources, auth detectors and ban actions — FailureSourceAuthFailureDetector and BanAction SPIs let other modules contribute events, recognise new authentication schemes, or react to bans (block in-process, email, webhook, custom).
  • Broad auth coverage out of the box — form login (and every SSO valve that sets VALVE_RESULT), HTTP Basic, Personal API tokens (Authorization: APIToken …) and the legacy jahiatoken header are all tracked.
  • Built-in actions: in-process block, email notification (throttled), webhook POST signed with HMAC-SHA256 (X-BFLP-Signature header).
  • Cluster-aware — state is shared across Jahia nodes via an embedded Hazelcast instance.
  • Audit log — every ban, unban, and config change is recorded and visible from the UI.
  • React admin UI with tabs for General settings, Jails, Bans, Audit log, and Integrations.

Dependencies & Dependants

Dependants
  • NONE

Changelog 3.0.0

3.0.0 is a full rewrite of the module to bring it in line with fail2ban
semantics. The 2.x model — one global threshold, "block after N consecutive
failures", JCR-only state, single-node only, form-login only — has been
replaced end-to-end. There is no automatic migration: re-enter your
settings from the admin UI after upgrading.


What changed vs 2.0.2

Detection model

Aspect 2.0.2 3.0.0
Trigger N consecutive failures (default 6) Sliding window: maxRetry failures within findTime
Configuration scope Single global threshold Multiple jails, each with own maxRetry / findTime / banTime / enabled flag
Repeat offenders None — same threshold every time Recidive escalation: progressively longer bans, capped by maxBanTime
Ban duration Until manual unban / cache flush Time-based, automatic unban; bans survive restart
State backend JCR cache Embedded Hazelcast instance, cluster-shared
Counter reset Manual via "Unblock" / "Flush cache" Sliding window expires naturally; manual unban still available

Authentication coverage

2.0.2 only inspected the legacy form-login valve. 3.0.0 tracks every
authenticated request through an ordered chain of detectors:

Order Detector Covers
100 FormLoginFailureDetector LoginEngineAuthValveImpl + every SsoValve subclass (anything that sets VALVE_RESULT)
200 BasicAuthFailureDetector Authorization: Basic …
300 ApiTokenAuthFailureDetector Authorization: APIToken … (personal-api-tokens)
400 JahiaTokenAuthFailureDetector legacy jahiatoken header (TokenAuthValveImpl)

The valve breaks on the first non-null FailureSignal, so each request
records at most one failure. Detector exceptions are isolated — one buggy
custom detector cannot break the chain.

In 2.0.2, Basic auth, Personal API tokens and jahiatoken requests
bypassed the listener entirely. Brute-force against those schemes was
effectively unprotected.

New extension points (SPI)

Three OSGi DS service interfaces let other modules plug in without
patching the core:

public interface AuthFailureDetector {       // recognise a new auth scheme
    FailureSignal detect(AuthFailureContext context);
    default int order() { return 500; }
}

public interface FailureSource { /* contribute failure events */ }
public interface BanAction     { /* react when an IP is banned */ }

Built-in BanActions shipped: in-process block, throttled email
notification, signed webhook POST. No script/exec action is provided
or supported
 — by design.

See README → Extending — adding a new auth-failure detector for a
worked example.

Storage & cluster

  • OSGi ConfigurationAdmin is now the source of truth for global
    settings and jail definitions. They live as .cfg files under
    <jahia-var>/karaf/etc/. Bans and the audit log remain JCR-backed
    (runtime state, not configuration).
  • Embedded Hazelcast replaces the JCR cache for tracker state and
    ban distribution across nodes.
  • Audit entries are auto-bucketed by yyyy/MM/dd under the audit-log
    container using jmix:autoSplitFolders, so the JCR fan-out stays
    bounded on busy installs.

Cluster hardening

  • Per-install group password. On first start the module generates a
    random 32-byte secret in
    <jahia-var>/karaf/etc/bflp-cluster-secret.properties (mode rw-------
    where supported). Override with -Dbflp.cluster.password=….

  • Mutual-TLS between members is opt-in:

    Property Purpose
    bflp.cluster.keystore Path to keystore (.jks / .p12)
    bflp.cluster.keystorePassword Keystore password
    bflp.cluster.truststore Path to truststore
    bflp.cluster.truststorePassword Truststore password

    Without these, cluster traffic between Hazelcast members is plaintext.

Network input hardening

  • Trusted-proxy gating. In 2.0.2 a single trustProxyHeader flag was
    enough to honour X-Forwarded-For — meaning any direct client that
    set the header bypassed CIDR filtering. In 3.0.0 the flag is necessary
    but no longer sufficient: the remote socket address must also match
    one of the CIDR entries in trustedProxyCidrs. A one-time warning is
    logged if the flag is on but the list is empty.
  • Signed webhooks. The webhook integration POSTs an X-BFLP-Signature
    HMAC-SHA256 header derived from the configured secret.
    URL validation (server-side) rejects private / loopback / link-local /
    multicast targets and the AWS metadata IP (169.254.169.254). Plain
    http:// receivers are opt-in only via -Dbflp.webhook.allowHttp=true.
  • Webhook secret at rest. Operator-pasted plaintext is re-encrypted
    on save ({enc} prefix). The GraphQL mutation honours a tri-state
    contract: null keeps the stored secret, "" clears it, anything else
    replaces it.

Hazelcast deserialization safety

The embedded Hazelcast registers a GlobalSerializerConfig with an
explicit allowlist of classes that may be deserialized from cluster
traffic. Unknown types are rejected. This addresses the classic
"deserialize anything off the wire" class of vulnerabilities that comes
with naive Hazelcast embedding.

Bearer tokens never leak into the audit log

Detectors that match on bearer-style headers (APITokenjahiatoken)
deliberately do not populate FailureSignal.username — the token
value is a secret, and username is rendered in the audit UI.

Admin UI

The legacy "Service status + threshold + CIDR whitelist + Tracked IPs"
screen is gone. The new React admin UI has five tabs:

  • General — protection on/off, IP whitelist (CIDR), username
    ignore patterns, trustProxyHeader + trustedProxyCidrs, recidive
    factor, max ban time, audit log size.
  • Jails — create / edit / delete jails (enabledmaxRetry,
    findTimeSecondsbanTimeSeconds).
  • Bans — currently-banned IPs; manual ban / unban.
  • Audit — paginated event browser; clear log.
  • Integrations — email recipient, webhook URL + secret, Test
    buttons
     for both. Webhook payload is Slack-compatible.

GraphQL API

The 2.x endpoint bruteForceLoginProtectionSettings with fields
nb_failed_login_max / time_to_idle has been removed. Use:

  • bruteForceLoginProtectionGlobalSettings for the global config.
  • Jail queries / mutations for per-jail thresholds.
  • Audit, bans and integration mutations as exposed in the new schema.

There is no compatibility shim — clients of the 2.x GraphQL API must be
updated.


Breaking changes — at a glance

  1. No automatic migration from 2.0.2 — re-enter settings after upgrade.
  2. JCR schema rewritten: old nb_failed_login_max / time_to_idle
    nodes are no longer read.
  3. GraphQL schema rewrittenbruteForceLoginProtectionSettings is
    gone; use bruteForceLoginProtectionGlobalSettings + jails.
  4. Settings moved out of JCR into OSGi ConfigurationAdmin
    (<jahia-var>/karaf/etc/). Existing v3 snapshot installs must also
    re-enter their settings.
  5. trustProxyHeader alone no longer trusts X-Forwarded-For —
    trustedProxyCidrs must list the legitimate front proxies.
  6. Bearer tokens are never recorded as usernames in audit events.
  7. Hazelcast Import-Package is now required (see Prerequisites).

Prerequisites (new in 3.0.0)

Jahia cluster mode must be enabled — the bundle imports
com.hazelcast.* packages that Jahia only exports when clustering is
active.

  • Tarball/installer: cluster.activated=true in
    digital-factory-config/jahia/jahia.node.properties, then restart.
  • Docker image: CLUSTER_ENABLED=true.

A single-node "cluster of one" is fine for dev/test. Without cluster
mode the bundle will fail to resolve with:

Unable to resolve brute-force-login-protection:
  missing requirement osgi.wiring.package=com.hazelcast.core

In a real cluster, OSGi ConfigurationAdmin storage is per-node — rely
on Karaf Cellar (shipped with Jahia clustering) to replicate
karaf/etc/, or mount a shared digital-factory-data/karaf/etc/. The
module itself does not broadcast settings over Hazelcast.


Operational notes for upgraders

  • Plan a maintenance window. Bans, settings and the GraphQL schema
    all change shape; you'll need to re-enter configuration once the new
    bundle is deployed.
  • Verify cluster mode is on before deploying — see the Prerequisites
    block above. A bundle that fails to resolve is the most common 3.0.0
    upgrade failure.
  • Audit-log receivers: if you scraped the old audit nodes directly
    from JCR, expect a different node structure (date-bucketed under
    yyyy/MM/dd).
  • Webhook receivers: verify the X-BFLP-Signature HMAC-SHA256
    header with a constant-time comparator
    (MessageDigest.isEqual in Java, hmac.compare_digest in Python,
    crypto.timingSafeEqual in Node.js).

Full Changelog2_0_2...3_0_0

FAQ

Configuration

Go to Administration → Server settings → Configuration → Brute force login protection.

  • General — toggle the protection on/off, define the IP whitelist (CIDR), ignore patterns for usernames, trust of X-Forwarded-For, recidive factor, max ban time, audit log size.
  • Jails — create/edit/delete jails. Each jail has: enabledmaxRetryfindTimeSecondsbanTimeSeconds.
  • Bans — view currently banned IPs, manually ban an IP, or unban one.
  • Audit — browse recent events, clear the log.
  • Integrations — configure the email recipient and the webhook URL/secret.

Where settings are stored

Global settings and jail definitions are persisted as OSGi configuration under <jahia-var>/karaf/etc/. Bans and the audit log remain JCR-backed (they are runtime state, not configuration).

Kind PID Example filename
Global settings (singleton) org.jahia.modules.bruteforceloginprotection.global org.jahia.modules.bruteforceloginprotection.global.cfg
Jail definition (factory) org.jahia.modules.bruteforceloginprotection.jail org.jahia.modules.bruteforceloginprotection.jail-login.cfg

A jail .cfg MUST contain a name=<jail-id> property — that string is the jail identifier seen by the engine and the admin UI. The filename discriminator after jail- is only used by Felix to uniquely key the configuration on disk.

Operator-pasted plaintext is supported for webhook_secret: the module re-encrypts it on the next save (prefix {enc}). The GraphQL mutation honours the same tri-state contract as before — null keeps the stored secret, "" clears it, any other value replaces it.

Default example .cfg files are shipped under src/main/resources/META-INF/configurations/ and are dropped into karaf/etc/ on first deploy.

Cluster behaviour

OSGi ConfigurationAdmin storage is per-node. In a Jahia cluster you must either:

  • rely on Karaf Cellar to synchronise karaf/etc/ across nodes (the default for clusters already running this module — Cellar is shipped with Jahia clustering), or
  • ensure all nodes mount/share the same digital-factory-data/karaf/etc/ directory.

No additional Hazelcast broadcasting of settings is performed by the module.

Webhook receiver guidance

When verifying the X-BFLP-Signature header on the receiver side, always use a constant-time comparison to prevent timing attacks on the HMAC. Concretely:

  • Java: java.security.MessageDigest.isEqual(expectedBytes, providedBytes)
  • Python: hmac.compare_digest(expected, provided)
  • Node.js: crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided))

The webhook URL is validated server-side to prevent SSRF: only https:// is accepted by default, private/loopback/link-local/multicast targets and the AWS metadata IP are rejected. Set -Dbflp.webhook.allowHttp=true to allow plain http:// for trusted on-premise receivers.

Trusted reverse proxies (X-Forwarded-For)

trustProxyHeader alone is no longer sufficient to honour X-Forwarded-For: in addition, the remote socket address of the incoming request must match one of the CIDR entries configured in trustedProxyCidrs. If the list is empty while the flag is on, the module logs a one-time warning and falls back to the raw socket address.

Configure Jahia's mail server settings to receive notification emails.

Extending — adding a new auth-failure detector

The auth pipeline valve dispatches every authenticated request through an ordered chain of AuthFailureDetector services. A custom module that wants to track a new authentication scheme just needs to register one:

@Component(service = AuthFailureDetector.class, immediate = true)
public class MySchemeFailureDetector implements AuthFailureDetector {
    @Override
    public FailureSignal detect(AuthFailureContext context) {
        if (context.isAuthenticated()) return null;
        String header = context.getRequest().getHeader("X-My-Scheme");
        if (header == null) return null;
        return FailureSignal.builder("my-scheme-valve")
                .extra("authScheme", "my-scheme")
                .build();
    }

    @Override
    public int order() { return 50; } // run before built-ins (100-400)
}

Built-in detector orders: form login = 100, HTTP Basic = 200, APIToken = 300, legacy jahiatoken = 400. Use < 100 to pre-empt a built-in or > 1000 to act as a fallback. The valve breaks on the first non-null FailureSignal so each request records at most one failure event.

Never copy bearer-token strings into FailureSignal.username — usernames land in the audit log.

Upgrading from 2.x

v3.0.0 is a breaking change. Both the JCR settings schema and the GraphQL schema were rewritten — there is no automatic migration. After upgrading the bundle, settings must be re-entered from the admin UI. The old nb_failed_login_max / time_to_idle / bruteForceLoginProtectionSettings GraphQL endpoint no longer exists; use jails and bruteForceLoginProtectionGlobalSettings instead.

In addition, starting from v3.0.0-SNAPSHOT the global settings and jail definitions moved out of JCR and into OSGi ConfigurationAdmin (see Where settings are stored above). Existing in-flight v3 snapshot installs must re-enter their settings — JCR values are no longer read.

How To Install

  • In Jahia, go to Administration → Server settings → System components → Modules
  • Upload the JAR brute-force-login-protection-X.X.X.jar
  • Check that the module is started

Images

License

MIT License

Copyright (c) 2019 - present Florent BOURASSÉ

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.