# Authentication Rate Limits

The website rate limits are implemented as shared CMS infrastructure. They do not depend on the active game source and must not be duplicated in `src/model/emulator/`, `src/model/stream/`, or another source-specific directory.

## Configuration

The policy is configured by `RATE_LIMITS` in:

- `src/settings/config.example.php`
- `src/settings/config.developer.php`

Current configuration:

```php
const RATE_LIMITS = [
  'login_ip' => [
    'max_attempts' => 10,
    'window_seconds' => 300,
    'block_seconds' => 900,
  ],
  'login_account' => [
    'max_attempts' => 20,
    'window_seconds' => 600,
    'block_seconds' => 900,
  ],
  'registration_ip' => [
    'max_attempts' => 1,
    'window_seconds' => 180,
  ],
];
```

`max_attempts` is inclusive: the attempt that reaches the limit creates the block when `block_seconds` is configured. Windows are rolling windows, not fixed calendar buckets.

## Database

The schema is in the V8 section of `installation/cms.sql`. Existing installations must apply the V8 table statements manually; the complete SQL file contains older destructive installation sections and is not a safe general-purpose migration runner.

### `cms_rate_limit_events`

Despite the historical `events` name, this table stores one aggregate row per scope and subject:

- `scope` and `subject` form the primary key.
- `attempts` stores the current counter.
- `creation_date` is the start of the current counting window.
- `blocked_until` stores the optional block expiration.

There is no `event_id`, no `subject_hash`, and no separate subject table. This keeps the database compact and makes the normal lookup a single primary-key lookup.

Subjects are stored as lowercase trimmed strings with a maximum length of 20 characters:

- IP subjects use the security limiter's `REMOTE_ADDR` value.
- Account subjects use the lowercase trimmed username.

The model rejects subjects longer than 20 characters. This matches the current username and IPv4 deployment assumptions; an IPv6-compatible subject representation must be designed before enabling this limiter behind IPv6-only or dual-stack traffic.

The model uses `Model::$connCMS`, so the rate-limit table must exist in the CMS database. When `CMS_DB` is not configured, the CMS connection aliases the game connection.

V8 recreates the previous rate-limit tables because hashed per-attempt rows cannot be converted back into readable aggregate subjects. Existing limiter state is intentionally disposable and is reset during this migration.

## Model

Implementation files:

- `src/model/base/rateLimit.model.php`
- `src/model/cms/rateLimit.model.php`

Load the model with:

```php
loadModel('rateLimit');
```

Public methods:

- `RateLimitModel::isBlocked($scope, $subject)` returns a boolean.
- `RateLimitModel::recordFailure($scope, $subject)` increments the counter and applies the configured block threshold.
- `RateLimitModel::reserve($scope, $subject)` increments the counter without creating a block; it is used for registration.
- `RateLimitModel::clear($scope, $subject)` removes the aggregate row for a subject.

Basic usage:

```php
loadModel('rateLimit');

$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if (RateLimitModel::isBlocked('login_ip', $ip)) {
  // Return the normal or AJAX error response without recording another event.
}

RateLimitModel::recordFailure('login_ip', $ip);
```

Record operations use a CMS transaction and `SELECT ... FOR UPDATE` on the aggregate row. This serializes concurrent requests from the same subject and prevents two requests from bypassing the threshold. Existing outer transactions are respected.

Expired rows are deleted lazily when any subject records a new event. The cleanup is global for that scope: it deletes all rows whose `creation_date` is older than that scope's configured window, except rows with an active `blocked_until`. There is currently no scheduled cleanup command. A scope with no new activity does not trigger cleanup until its next write.

## Login Behavior

The flow is in `src/pages/public/login/login.controller.php`.

1. Empty username/password validation happens first and does not consume a rate-limit event.
2. The IP block is checked first.
3. If the IP is not blocked, the username is looked up.
4. Existing accounts are checked against the account block.
5. If either the IP or account is blocked, the request stops without incrementing either limiter.
6. Otherwise, credentials are checked with the source-specific `AccountModel`.
7. A failed login records an IP event for every submitted username.
8. A failed login records an account event only when the username exists.
9. A successful login clears that account's account-limit events and block.
10. A successful login does not clear IP failures.

The response remains generic for blocked requests and failed credentials. The controller preserves both normal and AJAX response paths.

Typical controller structure:

```php
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$accountByUsername = null;
$accountKey = $username;

if (!RateLimitModel::isBlocked('login_ip', $ip)) {
  $accountByUsername = AccountModel::getByUsername($username);
  $accountKey = $accountByUsername['Username'] ?? $username;
}

$blocked = RateLimitModel::isBlocked('login_ip', $ip);
if (!$blocked && $accountByUsername) {
  $blocked = RateLimitModel::isBlocked('login_account', $accountKey);
}

if (!$blocked) {
  $account = AccountModel::getWithLogin($username, $password);
  if (!$account) {
    RateLimitModel::recordFailure('login_ip', $ip);
    if ($accountByUsername) {
      RateLimitModel::recordFailure('login_account', $accountKey);
    }
  } else {
    RateLimitModel::clear('login_account', $account['Username']);
  }
}
```

Do not call `recordFailure()` after `isBlocked()` returns true. This is what prevents blocked IP requests from incrementing account counters and locked-account requests from incrementing IP counters.

Important consequence: an account can accumulate failures across multiple IP addresses, but once an IP is blocked, requests from that IP do not add account failures. Once an account is blocked, retries do not add IP failures.

Account keys use the username returned by `AccountModel::getByUsername()` when available. This avoids separate counters when a user submits different casing for the same database username.

## Registration Behavior

The flow is in `src/pages/public/register/register.controller.php`.

The registration limiter is checked only after normal validation, CAPTCHA validation, and duplicate-username validation pass. A successful account creation consumes one `registration_ip` event for three minutes.

Account creation is a game-database operation while the reservation is in the CMS database, so the two operations cannot share one transaction when `CMS_DB` is separate. The reservation is cleared if account creation returns false or throws. If the process crashes after reserving but before completing account creation, the reservation expires naturally after three minutes.

The limiter uses `REMOTE_ADDR` directly. It does not use the existing `getClientIP()` helper because that helper trusts `HTTP_CLIENT_IP` and `HTTP_X_FORWARDED_FOR` from any client.

Registration reservation example:

```php
$reservation = RateLimitModel::reserve(
  'registration_ip',
  $_SERVER['REMOTE_ADDR'] ?? ''
);

if (!$reservation) {
  // The IP already registered within the configured window.
} else {
  try {
    $created = AccountModel::createAccount($name, $password, $email, $language);
    if (!$created) {
      RateLimitModel::clear('registration_ip', $_SERVER['REMOTE_ADDR'] ?? '');
    }
  } catch (Throwable $exception) {
    RateLimitModel::clear('registration_ip', $_SERVER['REMOTE_ADDR'] ?? '');
    throw $exception;
  }
}
```

Keep the reservation after successful account creation. clear it only when creation fails.

## Password Recovery

The forgotten-password flow is in `src/pages/public/forgotpassword/forgotpassword.controller.php`.

After the recovery token is validated and `AccountModel::updatePassword()` returns success, the account limiter is cleared immediately. Email delivery and token deletion happen afterward and do not determine whether the account is unlocked.

Authenticated password changes in `chpass` do not clear the login account limiter. Only the forgotten-password recovery flow has this unlock behavior.

## MFA Verification

The MFA controller uses two configured scopes:

- `mfa_ip` tracks failures from `REMOTE_ADDR`.
- `mfa_account` tracks failures for the account username.

Both scopes are checked before verifying an OTP. A failed but allowed verification records one failure in each scope. Blocked requests do not record additional failures. The same policy applies to login challenges and MFA removal; enrollment setup does not consume a failure event until a code is submitted.

Recovery unlock example:

```php
if (!AccountModel::updatePassword($username, $newPassword)) {
  // Do not unlock the account.
  return;
}

// Unlock immediately after the password update succeeds.
RateLimitModel::clear('login_account', $user['Username']);

// Email delivery and token deletion happen afterward.
```

## Source-Specific Authentication Details

The login controller is shared, but `AccountModel` is selected by `SOURCE_TYPE`. The account models differ in UID columns, account state columns, registration defaults, and stored registration IP behavior. The rate-limit model must remain source-independent and must not query those source-specific schema differences.

Authentication currently compares the submitted username and plaintext password in source-specific SQL. The rate limiter does not change that credential behavior.

The game account's ban/state tables are separate from this website limiter. Website login rate limits do not automatically enforce game account bans.

## Changing the Feature

When changing a policy:

1. Update `RATE_LIMITS` in the configuration examples and the deployed `config.php`.
2. Keep scope names stable because they are persisted in the CMS tables.
3. Update the relevant controller if the meaning of an event changes.
4. Update the V8 schema only for structural changes; do not rerun the full `cms.sql` against production.
5. Add coverage for the threshold boundary, rolling-window expiration, block expiration, and concurrent requests.

When adding a new rate-limited action, prefer a new scope and reuse `RateLimitModel` instead of creating another table or source-specific implementation.

Example for a new action:

```php
const RATE_LIMITS = [
  // Existing scopes...
  'password_recovery_ip' => [
    'max_attempts' => 3,
    'window_seconds' => 900,
    'block_seconds' => 1800,
  ],
];

loadModel('rateLimit');
$ip = $_SERVER['REMOTE_ADDR'] ?? '';

if (!RateLimitModel::isBlocked('password_recovery_ip', $ip)) {
  RateLimitModel::recordFailure('password_recovery_ip', $ip);
}
```

The scope must be present in `RATE_LIMITS` before the model is called. Scope names are stored in the database and should not be renamed casually.

## Test Coverage

Model-level coverage is in `tests/auto/integration-web/models/cms/rateLimitModelTest.php`. The cases intentionally use unique hashed subjects and clean their rows after the suite so they do not depend on a particular game source account.

Covered cases:

- The tenth IP failure creates a block.
- The twentieth account failure creates a block.
- A retry while the IP is blocked does not create an eleventh event.
- Account failures can be cleared.
- Registration allows one reservation and rejects the second reservation inside the window.
- Releasing a registration reservation allows another reservation.
- Recording any new event removes old events globally, including events from another subject.

Run the limiter model tests with:

```bash
phpunit --testdox \
  --bootstrap ./tests/auto/integration-web/bootstrap.php \
  tests/auto/integration-web/models/cms/rateLimitModelTest.php --debug
```

The CMS V8 table must exist before running these tests. Controller-level tests should additionally verify that blocked IP requests do not look up or increment accounts, locked accounts do not increment IP counters, and recovery unlocks immediately after a successful password update.
