Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/advanced/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ For each fail2ban rule:
1. Checks if the key is already banned -- if so, returns a blocked result immediately
2. If the filter matches, increments the failure counter and bans if the threshold is reached

Both the pre-handler path (during `decide()`) and the post-handler path (via `processRecordedFailure()`) use the same `count >= threshold` comparison: the Nth matching request triggers the ban and is itself blocked. This matches rack-attack's `maxretry` semantics and is consistent with Allow2Ban.
Both the pre-handler path (during `decide()`) and the post-handler path (via `processRecordedSignal()`) use the same `count >= threshold` comparison: the Nth matching request triggers the ban and is itself blocked. This matches rack-attack's `maxretry` semantics and is consistent with Allow2Ban.

See [Request Context](/advanced/request-context) for post-handler failure signaling.

Expand Down
94 changes: 67 additions & 27 deletions docs/advanced/request-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ outline: deep

# Request Context

The `RequestContext` API lets your application signal fail2ban failures **from inside the request handler** -- after the firewall has already passed the request through. This solves a fundamental limitation: standard fail2ban filters run _before_ your handler, so they cannot see whether credentials were valid, whether a payment failed, or whether an API key was revoked.
The `RequestContext` API lets your application signal **fail2ban failures** and **allow2ban hits** from inside the request handler -- after the firewall has already passed the request through. This solves a fundamental limitation: standard pre-handler filters cannot see whether credentials were valid, whether a payment failed, or whether an API key was revoked.

## The Problem

Expand All @@ -31,20 +31,20 @@ The flow has three stages:
└── Attaches a mutable RequestContext to the PSR-7 request attribute

2. Handler runs your application logic
└── Retrieves the context and calls recordFailure() if needed
└── Retrieves the context and calls recordFailure() / recordHit() if needed

3. Middleware runs post-handler processing
└── Processes all recorded failures through the fail2ban engine
└── Routes each recorded signal to its fail2ban or allow2ban evaluator
```

Here is what happens step by step:

1. The middleware calls the firewall's `decide()` method on the incoming request
2. If the request passes (is not blocked), the middleware creates a `RequestContext` and attaches it to the request as a PSR-7 attribute named `phirewall.context`
3. Your handler receives the request with the attached context
4. If your handler determines that the request represents a failure (wrong password, invalid API key, etc.), it calls `$context->recordFailure('rule-name', 'key')`
5. After your handler returns a response, the middleware checks for recorded failures and processes them through the fail2ban counter engine
6. If the failure count crosses the threshold, the key is banned for future requests
4. If your handler determines that the request represents a failure, it calls `$context->recordFailure('rule-name')`. For an allow2ban hit, it calls `$context->recordHit('rule-name')` instead. The key is derived from the matching rule's `keyExtractor`; pass an explicit second argument only when the handler knows a value the firewall cannot derive (e.g. a user id from a session).
5. After your handler returns a response, the middleware processes each recorded signal through the matching counter engine
6. If the count crosses the threshold, the key is banned for future requests

## Setup

Expand Down Expand Up @@ -78,7 +78,7 @@ The filter still exists because the fail2ban rule requires one. Setting it to al

## Recording Failures in Your Handler

Retrieve the `RequestContext` from the request attribute and call `recordFailure()`:
Retrieve the `RequestContext` from the request attribute and call `recordFailure()`. The second argument is optional -- when omitted, the firewall reuses the rule's own `keyExtractor` against this request, so the handler doesn't need to know whether the rule keys on IP, header, or anything else:

```php
use Flowd\Phirewall\Context\RequestContext;
Expand All @@ -98,9 +98,9 @@ class LoginHandler implements RequestHandlerInterface
/** @var RequestContext|null $context */
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);

// Signal the failure -- use the null-safe operator for safety
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
$context?->recordFailure('login-failures', $ip);
// Signal the failure -- the firewall derives the key from the
// rule's own keyExtractor. Use the null-safe operator for safety.
$context?->recordFailure('login-failures');

return new JsonResponse(['error' => 'Invalid credentials'], 401);
}
Expand All @@ -110,10 +110,49 @@ class LoginHandler implements RequestHandlerInterface
}
```

If the handler knows a discriminator that the firewall cannot derive from the request alone (for example, a user id looked up in a session store), pass it as the second argument:

```php
$context?->recordFailure('login-failures', $userIdFromSession);
```

::: warning Rule name must match
The first parameter to `recordFailure()` must **exactly** match the `name` you used in `$config->fail2ban->add()`. If no matching rule is found, the failure signal is silently ignored.
The first parameter to `recordFailure()` must **exactly** match the `name` you used in `$config->fail2ban->add()`. If no matching rule is found, the signal is silently ignored.
:::

## Recording Hits for Allow2Ban

`recordHit()` is the allow2ban counterpart of `recordFailure()`. It signals that something countable happened during the handler (e.g. an expensive operation completed, a webhook delivered a duplicate payload, a third-party API quota was charged) so the count can drive an allow2ban threshold ban:

```php
use Flowd\Phirewall\KeyExtractors;

// Configure an allow2ban rule. To make the rule count *only* the events
// recorded by the handler (not every request), have the rule's keyExtractor
// return null pre-handler -- the firewall then skips counting until the
// handler signals an explicit key via recordHit().
$config->allow2ban->add(
'expensive-endpoint',
threshold: 5,
period: 300,
banSeconds: 3600,
key: fn($request): ?string => null,
);
```

In the handler:

```php
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);

if ($context !== null && $this->operationWasExpensive($request)) {
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
$context->recordHit('expensive-endpoint', $ip);
}
```

If the rule's `keyExtractor` returns a value pre-handler (the common case), the second argument to `recordHit()` can be omitted -- the firewall derives the key the same way it does for `recordFailure()`. Note that in that case **both** the pre-handler counter and the handler's `recordHit()` increment the counter, so the threshold should account for the doubled count.

## API Reference

### RequestContext
Expand All @@ -122,32 +161,34 @@ The `RequestContext` class is a mutable recorder that the middleware attaches to

| Method | Signature | Description |
|--------|-----------|-------------|
| `recordFailure()` | `(string $ruleName, string $key): void` | Record a fail2ban failure signal |
| `recordFailure()` | `(string $ruleName, ?string $key = null): void` | Record a fail2ban failure signal |
| `recordHit()` | `(string $ruleName, ?string $key = null): void` | Record an allow2ban hit signal |
| `getResult()` | `(): FirewallResult` | Access the pre-handler firewall decision |
| `getRecordedFailures()` | `(): list<RecordedFailure>` | Get all recorded failure signals |
| `hasRecordedSignals()` | `(): bool` | Whether any failures have been recorded |
| `getRecordedSignals()` | `(): list<RecordedSignal>` | Get all recorded signals (fail2ban + allow2ban) |
| `hasRecordedSignals()` | `(): bool` | Whether any signals have been recorded |

**Constants:**

| Constant | Value | Description |
|----------|-------|-------------|
| `RequestContext::ATTRIBUTE_NAME` | `'phirewall.context'` | PSR-7 request attribute key |

### recordFailure() Parameters
### recordFailure() / recordHit() Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `$ruleName` | `string` | Must match the `name` of a configured `fail2ban->add()` rule |
| `$key` | `string` | The discriminator key to count failures against (e.g., IP address, username) |
| `$ruleName` | `string` | Must match the `name` of a configured `fail2ban->add()` (for `recordFailure`) or `allow2ban->add()` (for `recordHit`) rule |
| `$key` | `?string` | Optional discriminator override. When `null` (the default), the firewall extracts the key from the rule's own `keyExtractor` against the current request. |

### RecordedFailure
### RecordedSignal

An immutable value object representing a single failure signal.
An immutable value object representing a single recorded signal.

| Property | Type | Description |
|----------|------|-------------|
| `$ruleName` | `string` | The fail2ban rule this failure is recorded against |
| `$key` | `string` | The discriminator key |
| `$ruleName` | `string` | The fail2ban or allow2ban rule this signal is recorded against |
| `$banType` | `BanType` | `BanType::Fail2Ban` (from `recordFailure()`) or `BanType::Allow2Ban` (from `recordHit()`) |
| `$key` | `?string` | The discriminator override, or `null` to defer to the rule's `keyExtractor` |

## Accessing the Firewall Decision

Expand Down Expand Up @@ -179,10 +220,11 @@ When your handler might run without the Phirewall middleware in the stack (for e

```php
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
$context?->recordFailure('login-failures', $ip);
$context?->recordFailure('login-failures');
$context?->recordHit('expensive-endpoint');
```

If the middleware is not present, `$context` is `null` and the `recordFailure()` call is silently skipped -- no errors, no side effects. This makes your handler safe to use with or without Phirewall.
If the middleware is not present, `$context` is `null` and the calls are silently skipped -- no errors, no side effects. This makes your handler safe to use with or without Phirewall.

## Complete Example

Expand Down Expand Up @@ -229,8 +271,7 @@ $handler = new class implements RequestHandlerInterface {
if ($username !== 'admin' || $password !== 'secret') {
/** @var RequestContext|null $context */
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
$context?->recordFailure('login-failures', $ip);
$context?->recordFailure('login-failures');

return new Response(401, ['Content-Type' => 'application/json'],
json_encode(['error' => 'Invalid credentials'])
Expand Down Expand Up @@ -339,8 +380,7 @@ class RequestContextTest extends TestCase
public function handle(ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface
{
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? '0.0.0.0';
$context?->recordFailure('test-rule', $ip);
$context?->recordFailure('test-rule');
return new Response(401);
}
};
Expand Down
5 changes: 3 additions & 2 deletions docs/common-attacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ $config->fail2ban->add('login-failures',
// In your login handler:
if (!$this->authenticate($username, $password)) {
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
$context?->recordFailure('login-failures', $ip);
// No second argument needed -- the firewall extracts the key from the
// rule's own keyExtractor against this request.
$context?->recordFailure('login-failures');
}
```

Expand Down
7 changes: 4 additions & 3 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -1020,12 +1020,13 @@ $config->fail2ban->add('login-failures',
// In your login handler:
// $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
// if ($loginFailed) {
// $ip = $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
// $context?->recordFailure('login-failures', $ip);
// // The firewall derives the key from the rule's own keyExtractor
// // -- no need to repeat the IP/header/etc. extraction here.
// $context?->recordFailure('login-failures');
// }
```

The middleware automatically processes recorded failures after the handler returns. See [Request Context](/advanced/request-context) for the full API.
The middleware automatically processes recorded signals after the handler returns. Use `$context->recordHit('rule-name')` for allow2ban rules. See [Request Context](/advanced/request-context) for the full API.

---

Expand Down
7 changes: 4 additions & 3 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,12 @@ use Flowd\Phirewall\Context\RequestContext;

// In your handler, after authentication fails:
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
$context?->recordFailure('login-failures', $ip);
$context?->recordFailure('login-failures');
```

The matching Fail2Ban rule should use `filter: fn($request): bool => false` so it only counts failures signaled programmatically.
The second argument to `recordFailure()` is optional -- when omitted, the firewall extracts the discriminator key from the rule's own `keyExtractor`. The matching Fail2Ban rule should use `filter: fn($request): bool => false` so it only counts failures signaled programmatically.

For allow2ban rules, use `$context->recordHit('rule-name')` -- same shape, routed through the allow2ban evaluator instead.

### The old fluent API methods are gone — what do I use now?

Expand Down
27 changes: 17 additions & 10 deletions docs/features/fail2ban.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,13 @@ Middleware (pre-handler)
Your Handler
|
├── Checks credentials, validates input, etc.
├── On failure: $context->recordFailure('rule-name', $key)
├── On failure: $context->recordFailure('rule-name')
|
v
Middleware (post-handler)
|
├── Reads recorded failures from RequestContext
├── Increments fail2ban counters for each recorded failure
├── Reads recorded signals from RequestContext
├── Increments fail2ban / allow2ban counters per signal
|
v
Response
Expand Down Expand Up @@ -229,7 +229,7 @@ $config->fail2ban->add(

### Recording Failures in Your Handler

Inside your request handler, retrieve the `RequestContext` from the request attribute and call `recordFailure()`:
Inside your request handler, retrieve the `RequestContext` from the request attribute and call `recordFailure()`. The second argument is optional -- when omitted, the firewall reuses the rule's own `keyExtractor` against this request, so the handler doesn't need to repeat the IP/header/etc. extraction:

```php
use Flowd\Phirewall\Context\RequestContext;
Expand All @@ -242,10 +242,10 @@ class LoginController
$password = $request->getParsedBody()['password'] ?? '';

if (!$this->auth->verify($username, $password)) {
// Signal the failure to fail2ban
// Signal the failure -- the firewall extracts the key from
// the rule's own keyExtractor against this request.
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
$context?->recordFailure('login-failures', $ip);
$context?->recordFailure('login-failures');

return new Response(401, [], 'Invalid credentials');
}
Expand All @@ -255,12 +255,19 @@ class LoginController
}
```

Pass an explicit second argument only when the handler knows a discriminator the firewall cannot derive from the request alone (e.g. a user id looked up in a session store):

```php
$context?->recordFailure('login-failures', $userIdFromSession);
```

| Method | Description |
|--------|-------------|
| `$context->recordFailure(string $ruleName, string $key)` | Record a failure signal. `$ruleName` must match a configured fail2ban rule name. `$key` is the discriminator (e.g., IP address). |
| `$context->recordFailure(string $ruleName, ?string $key = null)` | Record a fail2ban failure signal. `$ruleName` must match a configured fail2ban rule. When `$key` is `null` the firewall derives it from the rule's `keyExtractor`. |
| `$context->recordHit(string $ruleName, ?string $key = null)` | Counterpart for allow2ban rules -- same shape, routed through the allow2ban evaluator. See [Request Context](/advanced/request-context#recording-hits-for-allow2ban). |
| `$context->getResult()` | Returns the `FirewallResult` from the pre-handler evaluation |
| `$context->hasRecordedSignals()` | Whether any failure signals have been recorded |
| `$context->getRecordedFailures()` | Returns all recorded `RecordedFailure` objects |
| `$context->hasRecordedSignals()` | Whether any signals have been recorded |
| `$context->getRecordedSignals()` | Returns all recorded `RecordedSignal` objects |

::: tip
Use the null-safe operator (`$context?->recordFailure(...)`) so your handler works safely both with and without the middleware in the stack -- useful in unit tests where the middleware may not be present.
Expand Down
Loading