Metadata-Version: 2.4
Name: nai-security
Version: 2.0.0
Summary: Django security app - IP/email/country blocking, rate limiting, login tracking, auto-blocking
Author-email: Ali Nemati <alinemati@nemati.ai>
License-Expression: MIT
Project-URL: Homepage, https://github.com/nematiai/nai-security
Project-URL: Documentation, https://nematiai.github.io/nai-security/
Project-URL: Repository, https://github.com/nematiai/nai-security
Project-URL: Issues, https://github.com/nematiai/nai-security/issues
Project-URL: NEMATI AI, https://nemati.ai
Keywords: django,security,ip-blocking,rate-limiting,country-blocking,email-blocking,login-tracking,geoip
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Security
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: django<7,>=5.2.18
Requires-Dist: geoip2<6,>=5.0
Requires-Dist: requests<3,>=2.33.0
Provides-Extra: axes
Requires-Dist: django-axes<9.0,>=8.3.1; extra == "axes"
Provides-Extra: import-export
Requires-Dist: django-import-export<5,>=4.0; extra == "import-export"
Provides-Extra: unfold
Requires-Dist: django-unfold<1,>=0.90; extra == "unfold"
Provides-Extra: celery
Requires-Dist: celery<6,>=5.6.0; extra == "celery"
Provides-Extra: redis
Requires-Dist: redis<9,>=7.4.0; extra == "redis"
Provides-Extra: all
Requires-Dist: django-axes<9.0,>=8.3.1; extra == "all"
Requires-Dist: django-import-export<5,>=4.0; extra == "all"
Requires-Dist: django-unfold<1,>=0.90; extra == "all"
Requires-Dist: redis<9,>=7.4.0; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest>=9.1; extra == "dev"
Requires-Dist: pytest-django>=4.14; extra == "dev"
Requires-Dist: pytest-cov>=7.1; extra == "dev"
Requires-Dist: mypy>=2.4; extra == "dev"
Requires-Dist: pip-audit>=2.10; extra == "dev"
Requires-Dist: pytest-randomly>=5.0; extra == "dev"
Requires-Dist: mutmut>=3.8; extra == "dev"
Requires-Dist: packaging>=26.3; extra == "dev"
Requires-Dist: ruff<0.18,>=0.17; extra == "dev"
Requires-Dist: build>=1.6; extra == "dev"
Requires-Dist: twine>=7.0; extra == "dev"
Provides-Extra: docs
Requires-Dist: mkdocs-material>=9.7; extra == "docs"
Dynamic: license-file

# NAI Security

[![PyPI version](https://img.shields.io/pypi/v/nai-security)](https://pypi.org/project/nai-security/)
[![Django Packages](https://img.shields.io/badge/Django_Packages-nai--security-8c3c26)](https://djangopackages.org/packages/p/nai-security/)

Django security package for IP blocking, country blocking, email blocking, rate limiting, and login tracking.

**Docs:** https://nematiai.github.io/nai-security/  
**Wiki:** https://github.com/nematiai/nai-security/wiki  
**Wiki source in repo:** [`wiki/`](https://github.com/nematiai/nai-security/tree/main/wiki)  
**NEMATI AI:** https://nemati.ai

## Features

- **IP Blocking** - Block specific IPs manually or automatically
- **Country Blocking** - Block/allow countries using GeoIP
- **Email Blocking** - Helpers + admin lists for signup/login in your app (not request middleware)
- **Domain Blocking** - Helpers + admin lists for disposable/spam domains
- **User Agent Blocking** - Block bots, scrapers, attack tools
- **Header Stripping** - Remove `Server`, `X-Powered-By` and similar so scanners cannot fingerprint the stack
- **Injection Signatures** - Match requests against SQLi, NoSQL, GraphQL, LDAP, XPath, SSTI, XXE and HQL patterns across query string, form body, cookies, User-Agent and Referer. **Log-only by default**
- **Passive Response Scan** - Opt-in inspection of *our own* responses for internal IPs, database errors, stack traces, key material, secrets in HTML comments and missing security headers
- **Deploy Checks** - `manage.py check --deploy` flags exposure Django's own checks miss (dangerous routes, default admin prefix, Django-served static, `ALLOWED_HOSTS = ['*']`, open CORS, an unset/bogus proxy count, an unreadable GeoIP database, a `robots.txt` that maps your secrets)
- **Path Blocking** - Reject scanner probes for dotfiles (`/.git`, `/.env`, `/.ssh`), leak paths (`/server-status`, `/phpinfo`) and leftover files by extension (`.bak`, `.sql`, `.log`, `.php`), keeping `/.well-known/` reachable
- **Rate Limiting** - Enforces admin-configured `RateLimitRule` path limits
- **Login History** - Track user logins with anomaly detection
- **Auto-Blocking** - Automatically block IPs/countries based on attack patterns
- **Security Logs** - Comprehensive logging of all security events
- **Axes Integration** - Dynamic login attempt limits, cooloff time, and per-attempt expiry via admin panel (requires django-axes >= 8.3)
- **Whitelisted Users** - Exempt specific users from security checks

## Installation

```bash
pip install nai-security
```

With Django integration extras (axes, import-export, unfold, redis):

```bash
pip install nai-security[all]
```

Each extra also needs its app in `INSTALLED_APPS` to become active (`"axes"`, `"unfold"`,
`"import_export"`). Installing an extra without listing its app is safe — the feature is just off.

Background tasks extra (not included in `[all]`):

```bash
pip install nai-security[celery]
```

Or install from GitHub:

```bash
pip install git+https://github.com/nematiai/nai-security.git
```

## Quick Start

### 1. Add to INSTALLED_APPS

```python
INSTALLED_APPS = [
    ...
    "nai_security",
]
```

### 2. Add Middleware

> **Important:** `nai_security.middleware.SecurityMiddleware` **must** be placed **after** `django.contrib.auth.middleware.AuthenticationMiddleware`. The package validates this at startup and raises `ImproperlyConfigured` if misordered.

```python
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",  # must be before
    "django.contrib.messages.middleware.MessageMiddleware",
    "nai_security.middleware.SecurityMiddleware",  # after auth
    "nai_security.middleware.RateLimitLoggingMiddleware",  # optional
    "nai_security.middleware.PassiveScanMiddleware",  # optional, see below
]
```

`PassiveScanMiddleware` reads every response body it is allowed to see. It is opt-in for that
reason: add it once you want leak reporting, and cap the read with
`NAI_SECURITY_PASSIVE_MAX_BYTES` (default 65536). It never blocks and never edits a response.

### 3. Configure Settings

```python
GEOIP_PATH = "/path/to/GeoLite2-Country.mmdb"

# Optional: override default exempt paths (default: /health/, /ready/, /favicon.ico).
# A trailing '*' exempts a whole subtree. An exempt path skips EVERY check.
NAI_SECURITY_EXEMPT_PATHS = ["/health/", "/health", "/ready/", "/ready", "/favicon.ico"]

# The NUMBER of reverse proxies in front of this app, not a boolean. One nginx = 1,
# nginx behind Cloudflare = 2. Default 0 ignores X-Forwarded-For so clients cannot
# spoof their IP. X-Real-IP is never trusted. Get the count wrong and every client
# IP is wrong -- see Configuration -> Reverse proxies, and check nai_security.W007.
NAI_SECURITY_TRUST_PROXY_HEADERS = 1

# Who may set X-Forwarded-For. Default: loopback + private ranges, which covers
# nginx/gunicorn on the same host. A proxy on a PUBLIC address (cloud LB, CDN)
# must be listed here or its clients all resolve to the proxy. Listing replaces
# the defaults. NAI_SECURITY_APPROVED_PROXIES = ["173.245.48.0/20"]
```

### 4. Run Migrations

```bash
python manage.py migrate
```

### 5. Download GeoIP Database

```bash
python manage.py download_geoip
```

## Dependencies

**Required:**
- Django >= 5.2.18, < 7
- geoip2 >= 5.0, < 6
- requests >= 2.33.0, < 3

**Optional:**
- `django-axes >= 8.3.1, < 9` — login attempt tracking and lockout; without it axes features are silently disabled
- `django-import-export >= 4.0, < 5` — admin import/export for blocked emails/domains; without it those buttons are hidden
- `django-unfold >= 0.90, < 1` — admin UI theme; without it falls back to standard Django admin (0.100+ needs Python >= 3.12)
- `redis >= 7.4.0, < 9` — for Django's Redis cache backend (`pip install nai-security[redis]`, included in `[all]`)
- `celery >= 5.6.0, < 6` — background tasks (auto-block processing, sync, reports); without it tasks are no-ops (`pip install nai-security[celery]`)

## Axes Integration

Enable brute-force protection with dynamic settings controlled from the admin panel:

```python
# settings.py
INSTALLED_APPS = [
    ...
    "axes",
    "nai_security",
]

AXES_HANDLER = 'nai_security.handlers.axes_integration.DynamicAxesHandler'

AUTHENTICATION_BACKENDS = [
    'axes.backends.AxesStandaloneBackend',
    'django.contrib.auth.backends.ModelBackend',
]
```

This gives you admin-configurable control over:

| Setting | Description |
|---------|-------------|
| **Max login attempts** | Failed attempts before lockout (default: 5) |
| **Cooloff time** | Minutes before locked accounts auto-unlock (0 = permanent) |
| **Attempt expiry** | Each failed attempt expires independently — requires cooloff > 0 |

All changes take effect immediately — no server restart required.

> **Validation:** Enabling attempt expiry with cooloff set to 0 will raise a validation error in the admin panel.

### Whitelist bypass

`DynamicAxesHandler` short-circuits all axes checks (`is_allowed`, `is_locked`, `user_login_failed`) when the request matches **any** active whitelist:

- The client IP (`REMOTE_ADDR`, or the `X-Forwarded-For` entry `NAI_SECURITY_TRUST_PROXY_HEADERS` hops from the right) appears in `WhitelistedIP` — bypass works even when the request has no credentials (e.g. `GET /login/`).
- The login username/email resolves to a user with an active `WhitelistedUser` row — **regardless of `exemption_type`**. The `exemption_type` field still controls middleware-level bypasses (IP, country, rate-limit); for axes lockout the rule is binary.
- Username lookup tolerates `USERNAME_FIELD='username'` deployments where the login form posts an email — falls back to `email__iexact` automatically.

When a whitelisted request fails authentication, no `AccessAttempt` row is recorded — the table stays clean for whitelisted admins.

## Management Commands

```bash
# Download GeoIP database (checksum-verified, exits non-zero on failure)
python manage.py download_geoip
python manage.py download_geoip --release 2026.10.01 --sha256 <digest>

# Sync disposable email domains and bad bot lists
python manage.py sync_security_lists
python manage.py sync_security_lists --domains-only
python manage.py sync_security_lists --bots-only
```

## Celery Tasks

```python
from celery.schedules import crontab

CELERY_BEAT_SCHEDULE = {
    'security-auto-blocks': {
        'task': 'security.process_auto_blocks',
        'schedule': crontab(minute='*/5'),
    },
    'security-cleanup-expired': {
        'task': 'security.cleanup_expired_blocks',
        'schedule': crontab(minute=0, hour='*'),
    },
    'security-cleanup-logs': {
        'task': 'security.cleanup_security_logs',
        'schedule': crontab(minute=30, hour=3),
    },
    'security-sync-lists': {
        'task': 'security.sync_security_lists',
        'schedule': crontab(minute=0, hour=0, day_of_week=0),
    },
    'security-daily-report': {
        'task': 'security.generate_security_report',
        'schedule': crontab(minute=0, hour=6),
    },
}
```

## Models

| Model | Description |
|-------|-------------|
| `BlockedIP` | Blocked IP addresses |
| `BlockedCountry` | Blocked countries |
| `AllowedCountry` | Allowed countries (whitelist mode) — an empty list denies every country |
| `BlockedEmail` | Blocked email addresses (helpers for signup/login in **your** app — not request middleware) |
| `BlockedDomain` | Blocked email domains (helpers for signup/login in **your** app — not request middleware) |
| `BlockedUserAgent` | Blocked user agents |
| `WhitelistedIP` | IPs that bypass all checks |
| `WhitelistedUser` | Users exempted from security checks (see exemption types below) |
| `RateLimitRule` | Path-prefix rate limits, enforced by `RateLimitLoggingMiddleware` (429). Per-view limits stay with django-ratelimit |
| `LoginHistory` | User login tracking |
| `SecurityLog` | Security event logs |
| `SecuritySettings` | Global settings (singleton) |

## User Exemptions (WhitelistedUser)

Exempt specific users from security checks via the admin panel or ORM:

| Exemption Type | Bypasses |
|---------------|----------|
| `all` | Entire security middleware — IP, country, user-agent |
| `ip_block` | IP blocking only |
| `geo_block` | Country/geo blocking only |
| `rate_limit` | `RateLimitRule` enforcement and rate limit logging |

Exemptions support optional expiration (`expires_at`) and can be toggled via `is_active`.

> **Axes lockout note:** any active `WhitelistedUser` row exempts the user from django-axes lockout, regardless of `exemption_type`. The `exemption_type` field controls only the `SecurityMiddleware` checks (IP/country/rate-limit). See [Axes Integration → Whitelist bypass](#whitelist-bypass).

## Upgrading to `2.0.0`

**Breaking. Two migrations (`0008`, `0009`).**

**Injection signatures run on every request, log-only.** `SecuritySettings.signature_detection_enabled`
is on by default and writes a `SIGNATURE_MATCH` row to `SecurityLog` when a request matches one of
the thirteen patterns. Nothing is blocked until you also set `signature_blocking_enabled` — watch
your own traffic first, because a signature set tuned elsewhere will match something you serve.
Turn detection off entirely with `signature_detection_enabled = False`.

**`PassiveScanMiddleware` is new and opt-in.** It is not added to `MIDDLEWARE` for you. Once added,
`SecuritySettings.passive_scan_enabled` (default on) gates it and `NAI_SECURITY_PASSIVE_MAX_BYTES`
caps how much of each body is read. Findings land as `PASSIVE_ALERT` rows.

**New check `nai_security.W009`** reads `robots.txt` from `STATICFILES_DIRS`, `STATIC_ROOT` or
`BASE_DIR` and warns when a `Disallow:` entry names your admin or a path the perimeter already
blocks — that file is the first thing a scanner reads.

**`NAI_SECURITY_TRUST_PROXY_HEADERS` is a proxy count, not a boolean.** Set it to the number of
reverse proxies in front of the app — `1` for one nginx, `2` for nginx behind Cloudflare. The client
sits that many hops from the **right** of `X-Forwarded-For`, because each proxy appends the peer it
heard from and the leftmost entry is whatever the client prepended. `True` still means `1` and a
digit string (`"3"`) is read, but a negative, non-numeric, or wrong-typed value now resolves to
**0 — trust nothing** — instead of silently meaning 1. An install behind two or more proxies that
leaves this at `True` reads the inner proxy's address as the client on every request. New check
`nai_security.W007` reports it.

**Forwarded headers are only honoured from an approved proxy.** New setting
`NAI_SECURITY_APPROVED_PROXIES` — the hop count is applied only when `REMOTE_ADDR` is one of these
networks, because a chain arriving from any other peer was written by the client. Unset, it approves
loopback and the private ranges, so nginx or gunicorn on the same host or network needs no change.
**If your proxy reaches the app from a public address** (a cloud load balancer, a CDN), list its
CIDRs — otherwise every client behind it now resolves to the proxy itself. Listing replaces the
defaults. `nai_security.W007` reports a proxy count set without a list.

**`X-Real-IP` is no longer trusted.** Only `X-Forwarded-For` is read, and a chain shorter than the
count falls back to `REMOTE_ADDR`. The client sets `X-Real-IP` as easily as a proxy does.

**The loopback bypass now needs a loopback peer *and* no forwarded headers.** nginx proxying to
gunicorn on `127.0.0.1` — or over a unix socket, where `REMOTE_ADDR` is empty — otherwise made every
request look local and skipped the whole perimeter.

**Both country modes fail closed.** An IP whose country cannot be resolved is now denied, not
allowed; making the GeoIP database unreadable used to wave everyone through. Blocklist mode fails
closed only while a `BlockedCountry` row exists — an install that blocks no country is unaffected —
and the denial is logged as `COUNTRY_BLOCK` with an empty country code. Allowlist mode denies
regardless. Add a `WhitelistedIP` row for your own address first, or a missing `.mmdb` locks you out
of the admin too.

**An empty allowlist now denies everything.** With `country_whitelist_mode` on and zero active
`AllowedCountry` rows — a fresh tick of the box, or the last row deleted — every country used to be
allowed, the opposite of what the box says. It is now a lockdown, logged as
`COUNTRY_WHITELIST_BLOCK`, with the same three escapes as above: `WhitelistedIP`, a `WhitelistedUser`
with `geo_block` or `all`, and an unforwarded loopback client. Add the countries **before** ticking
the box. New check `nai_security.W008` warns when country rules are on and the database is
unreadable.

**`RateLimitRule` now enforces.** The model was admin-editable and read by nothing, so every
limit an operator configured did nothing at all. `RateLimitLoggingMiddleware` now checks the active
rules before the view runs and returns **429** past the limit, counting per client IP in a fixed
window in the cache. The most specific (longest) matching path prefix wins, `method` scopes a rule to
one HTTP verb, and the whole set lives under one cache key — an empty table costs one cache read and
no query. **Existing rows start enforcing on upgrade**: review `RateLimitRule` before deploying, or
deactivate the rows you were only using as notes. django-ratelimit is unchanged and not replaced — it
stays the per-view decorator, and a request denied here never reaches it, so nothing is counted twice.
Exempt paths, loopback clients, `WhitelistedIP`, and `WhitelistedUser` with `rate_limit` or `all` are
never limited.

**`email_blocking_enabled` and `domain_blocking_enabled` now do something.** They were admin-visible
and read nowhere. `BlockedEmail.is_email_blocked()` and `BlockedDomain.is_domain_blocked()` return
`False` while their toggle is off, so an install that unticked either one and relied on the block
still applying loses it.

**Security logs are pruned.** New `log_retention_days` on `SecuritySettings` (default **90**, `0`
keeps everything) and a new `security.cleanup_security_logs` Celery task that deletes older rows in
batches. Migration `0008` adds the field. Nothing prunes unless you schedule the task — add it to
`CELERY_BEAT_SCHEDULE`, and set the retention you are willing to defend before the first run.

**`NAI_SECURITY_EXEMPT_PATHS = ['*']` now raises `ImproperlyConfigured` at startup** — it exempted
every request and silently disabled the middleware. Remove the middleware instead. Trailing-`*`
prefixes still work, but now anchor on `/` segments and normalize both sides, so `/export*` no longer
covers `/exports-internal/` and `/exports/*` is not reachable as `/exports/../.env`. Note that an
exempt path skips **every** check and is matched before `SecuritySettings` is loaded — that is what
lets `/health/` answer with the database down, and it means an exempt path is unprotected.

**`download_geoip` refuses unverified downloads and exits non-zero.** A `--release` (or
`NAI_SECURITY_GEOIP_RELEASE`) other than the pinned default with no `--sha256` (or
`NAI_SECURITY_GEOIP_SHA256`) now raises `CommandError` and installs nothing; pass
`--allow-unverified` to accept the mirror's bytes. Every failure exits non-zero where the previous
version printed a warning and exited 0, so cron jobs will start reporting failures they swallowed.

Also: `SecurityLog` rows can no longer be deleted from the admin; a `BlockedUserAgent` regex
containing a nested quantifier (`(a+)+`) is rejected on save and regex matching is capped at the
first 512 characters of the User-Agent; `WhitelistedUser`'s user picker is a raw id rather than an
autocomplete, which used to raise `admin.E040` in projects whose user `ModelAdmin` has no
`search_fields`.

**New check `nai_security.W010`** (Django 6.0+ only) warns when no Content-Security-Policy is sent —
`ContentSecurityPolicyMiddleware` missing, or `SECURE_CSP` and `SECURE_CSP_REPORT_ONLY` both empty. A
policy set by your proxy is invisible to it: add it to `SILENCED_SYSTEM_CHECKS`.

**Dependencies.** `redis` is no longer installed by default. It is only needed for Django's Redis
cache backend: install `nai-security[redis]` (also part of `[all]`). Floors are raised to the
security-fixed releases: Django 5.2.18, requests 2.33.0, celery 5.6.0, redis 7.4.0; django-unfold is
capped below 1.0. The `[ratelimit]` extra is gone — nothing in the package imports
django-ratelimit; if you use its decorators, depend on `django-ratelimit` directly.

## Upgrading to 1.18.0

Adds checks `nai_security.W005` (`ALLOWED_HOSTS` wildcard — Django's `W020` only catches an empty
list) and `nai_security.W006` (open CORS). No migration, no API change.

## Upgrading to 1.17.0

Path blocking now also matches file extensions — backup/editor leftovers, database dumps, key
material, and scripts Django never executes. Root-level archives too. **Behaviour change:** if a real
route ends in one of those, add it to `NAI_SECURITY_EXEMPT_PATHS`.

## Upgrading to 1.16.0

Adds `ResponseHeaderMiddleware`, which strips stack-identifying response headers, and check
`nai_security.W004`, which warns when it is not installed. **Opt-in** — add it to `MIDDLEWARE`
(first, so it sees the final response). No migration, no API change.

## Upgrading to 1.15.0

Adds three `deploy`-tagged system checks (`nai_security.W001`–`W003`) that inspect the URLconf for
exposure Django's settings-only checks miss. Run `manage.py check --deploy`. No migration, no API
change, no request-time behaviour change.

## Upgrading to 1.14.1

Bug-fix only. Two startup crashes on installs without the full extra set: a duplicate-base
`TypeError` when `django-import-export` is absent, and a `RuntimeError` when `django-axes` is
installed but not in `INSTALLED_APPS`. No API change, no migration.

## Upgrading to 1.14.0

**New Path Blocking check (two migrations).** Run `python manage.py migrate` — `0006` adds
`SecuritySettings.path_blocking_enabled`, **default on**. Dotfile paths (`/.git`, `/.env`, `/.ssh`, …)
and `/server-status`, `/server-info`, `/phpinfo`, `/wp-config.php`, `/web.config`, `/id_rsa` now
return 403 and log a `PATH_BLOCK` event; `0007` adds `PATH_BLOCK` to the `SecurityLog.action` choices. `/.well-known/` stays reachable (ACME renewal is
unaffected). Disable it in admin → Security Settings.

## Upgrading to 1.13.0

**Install-time / support matrix (no app API change):**

- Required Django floor is now **5.2** (4.2 and 5.0 are past end of support).
- Required `requests` floor is now **2.32.4**.
- New optional extra: `pip install nai-security[celery]` (`celery>=5.3,<6`). `[all]` is unchanged (axes, ratelimit, import-export, unfold).
- Dev extra adds pytest-cov, hypothesis, time-machine, responses, fakeredis, model-bakery, mypy, and pip-audit.

## Upgrading to 1.12.2

Metadata only: NEMATI AI links (https://nemati.ai) on PyPI, docs, and wiki. No app API change.

## Upgrading to 1.12.1

**Docs links:** PyPI Documentation now opens https://nematiai.github.io/nai-security/ (no app API change).

## Upgrading to 1.12.0

**Security / behavior fix (no migrations):**

- **If you run behind a reverse proxy, set** `NAI_SECURITY_TRUST_PROXY_HEADERS`. Forwarded headers are ignored by default so clients cannot spoof IP to bypass blocks. *(Historical: this setting was a boolean in 1.12.0. It is a **proxy count** as of `2.0.0` — see [Upgrading to `2.0.0`](#upgrading-to-200).)*
- Axes cooloff and attempt-expiry now apply on the next request in every worker (not only the process that saved admin).
- Whitelisting a user with any `exemption_type` now clears an active axes lockout.
- Default bad-bot sync no longer blocks `python-requests` / `curl` / `wget` / `Go-http-client`.
- `GEOIP_PATH` accepts a directory or the `.mmdb` file. Classifiers add Django 6.1 and Python 3.14.

## Upgrading to 1.11.0

**Dependency pin updates (install-time):**

- Declared `requests>=2.28` as a required dependency (used by sync services).
- Raised/capped floors: `geoip2>=5.0,<6`, `redis>=5.0,<9`.
- Optional extras: `django-import-export>=4.0,<5`, `django-unfold>=0.90`, `django-ratelimit>=4.1`.
- `django-axes` remains `>=8.3.1,<9.0`.
- No application API changes in this release.

## Upgrading to 1.10.1

**Bug fixes (no breaking changes):**

- **Whitelisted users were still being locked out by django-axes.** Three independent paths caused this:
  1. `WhitelistedIP` was not consulted by the axes handler — whitelisted IPs could still be locked, especially with `AXES_LOCKOUT_PARAMETERS=['ip_address']`.
  2. The handler's whitelist check was hard-coded to `exemption_type='all'` — users with `'ip_block'`, `'rate_limit'`, or `'geo_block'` were still locked.
  3. User lookup used `USERNAME_FIELD` only and silently failed when the login form posts an email but `USERNAME_FIELD='username'`.
- All three are fixed in `nai_security.handlers.axes_integration.DynamicAxesHandler` via a unified `_is_request_whitelisted()` helper. Failed login attempts from whitelisted requests are no longer recorded in `AccessAttempt`.
- Verified end-to-end with 100x failed-login smoke test (`scripts/smoke_100x_lockout.py`) and 9 regression tests in `tests/test_axes_integration.py`.

## Upgrading to 1.9.1

**Breaking changes:**

- `SecurityMiddleware` now **requires** `AuthenticationMiddleware` to be placed before it in `MIDDLEWARE`. If misordered, the app raises `ImproperlyConfigured` at startup. Previously the middleware silently failed to resolve users.
- `NAI_SECURITY_USER_RESOLVER` setting has been **removed**. User resolution now uses `request.user` directly (guaranteed by middleware ordering).
- `parse_user_agent` now correctly detects Android, iOS, and Opera (previously misidentified as Linux, macOS, and Chrome respectively).

## Testing

```bash
uv sync --all-extras
make test
make test-floors   # Python 3.11 at the lowest declared versions
PYTHONPATH=. uv run pytest --cov=nai_security --cov-report=term-missing
uv run pip-audit
```

## License

MIT License

## Author

Ali Nemati - [NEMATI AI](https://nemati.ai)
