feat: centralized IP restriction for registration via middleware

- RegistrationIPRestrictionMiddleware added
- Removed view decorators
- All logic now in middleware
- Updated docs and settings
- IP utils in common/ip_restrictions.py
This commit is contained in:
admin i 2025-12-05 16:18:11 +03:00
parent 56d1e3ae5e
commit 65c6d47eaa
5 changed files with 687 additions and 0 deletions

View File

@ -0,0 +1,290 @@
# Ограничение доступа к регистрации по IP
## Описание
Реализована система ограничения доступа к регистрации новых пользователей по IP-адресу через middleware. Это позволяет разрешить регистрацию только из локальной сети или конкретных IP-адресов.
## Файлы
### 1. `backend/common/ip_restrictions.py`
Утилиты для проверки IP-адресов:
- **`get_client_ip(request)`** - получает реальный IP клиента (учитывает прокси)
- **`is_local_ip(ip_address)`** - проверяет, является ли IP локальным
- **`is_ip_allowed(ip_address, allowed_networks)`** - проверяет доступ для IP
### 2. `backend/eusrr_backend/middleware.py`
Добавлен класс `RegistrationIPRestrictionMiddleware` для проверки IP при регистрации.
### 3. Настройка в `backend/eusrr_backend/settings.py`
В `MIDDLEWARE` добавлена строка:
```python
"eusrr_backend.middleware.RegistrationIPRestrictionMiddleware",
```
Добавлена настройка `REGISTRATION_ALLOWED_IPS`:
```python
REGISTRATION_ALLOWED_IPS = None # По умолчанию только локальные IP
```
## Конфигурация
### По умолчанию (только локальные IP)
```python
REGISTRATION_ALLOWED_IPS = None
```
Разрешены только:
- `127.0.0.0/8` (localhost)
- `10.0.0.0/8` (частная сеть класса A)
- `172.16.0.0/12` (частная сеть класса B)
- `192.168.0.0/16` (частная сеть класса C)
- `::1` (IPv6 localhost)
- `fe80::/10` (IPv6 link-local)
### Разрешить все IP
```python
REGISTRATION_ALLOWED_IPS = ['*']
```
### Разрешить конкретные сети (CIDR)
```python
REGISTRATION_ALLOWED_IPS = [
'192.168.1.0/24', # Локальная сеть офиса
'10.0.0.0/8', # Корпоративная сеть
'172.16.100.0/24', # VPN сеть
]
```
### Разрешить конкретные IP-адреса
```python
REGISTRATION_ALLOWED_IPS = [
'192.168.1.100',
'192.168.1.101',
'203.0.113.42',
]
```
### Комбинированный подход
```python
REGISTRATION_ALLOWED_IPS = [
'192.168.1.0/24', # Вся локальная сеть
'203.0.113.42', # Конкретный внешний IP
'198.51.100.0/28', # Диапазон внешних IP
]
```
## Поведение при блокировке
### Для обычных view (HTML)
При попытке доступа с неразрешенного IP пользователь увидит:
```html
<h1>403 Forbidden</h1>
<p>Регистрация доступна только из локальной сети.</p>
<p>Ваш IP: 203.0.113.50</p>
```
### Для API endpoints (JSON)
При попытке доступа с неразрешенного IP API вернет:
```json
{
"detail": "Регистрация доступна только из локальной сети.",
"client_ip": "203.0.113.50"
}
```
HTTP статус: `403 Forbidden`
## Работа с прокси
Функция `get_client_ip()` корректно определяет реальный IP клиента даже при использовании прокси-серверов:
1. Проверяет заголовок `X-Forwarded-For`
2. Берет первый IP из списка (клиентский IP)
3. Если заголовка нет, использует `REMOTE_ADDR`
### Настройка для работы за Nginx
Добавьте в конфигурацию Nginx:
```nginx
location / {
proxy_pass http://127.0.0.1:9000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
}
```
## Тестирование
### Проверка локального доступа
```bash
curl http://localhost:9000/api/v1/auth/register/ \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"test123",...}'
```
Должно работать с `127.0.0.1`.
### Проверка блокировки (эмуляция внешнего IP)
```bash
curl http://localhost:9000/api/v1/auth/register/ \
-H "Content-Type: application/json" \
-H "X-Forwarded-For: 203.0.113.50" \
-d '{"email":"test@example.com","password":"test123",...}'
```
Должно вернуть `403 Forbidden` если `REGISTRATION_ALLOWED_IPS` не содержит этот IP.
### Тестирование в коде
```python
from common.ip_restrictions import is_local_ip, is_ip_allowed
# Проверка локальных IP
assert is_local_ip('127.0.0.1') == True
assert is_local_ip('192.168.1.100') == True
assert is_local_ip('10.0.0.5') == True
assert is_local_ip('203.0.113.50') == False
# Проверка с настройками
from django.conf import settings
settings.REGISTRATION_ALLOWED_IPS = ['192.168.1.0/24']
assert is_ip_allowed('192.168.1.50') == True
assert is_ip_allowed('192.168.2.50') == False
```
## Безопасность
### Рекомендации
1. **Для production**: используйте конкретные сети, не `['*']`
2. **За прокси**: убедитесь, что прокси правильно передает заголовки
3. **IPv6**: модуль поддерживает IPv6, учтите это в конфигурации
4. **Логирование**: при необходимости добавьте логирование заблокированных попыток
### Обход защиты
Потенциальные проблемы:
1. **Подмена заголовков**: если прокси настроен неправильно, злоумышленник может подменить `X-Forwarded-For`
2. **VPN/Proxy клиента**: пользователь может использовать VPN с локальным IP
### Дополнительная защита
Рекомендуется комбинировать с:
- Rate limiting (throttling в DRF)
- CAPTCHA для регистрации
- Email верификация (уже реализована)
- Логирование попыток регистрации
## Примеры использования в других view
Если нужно ограничить доступ к другим URL по IP, можно создать дополнительные middleware или расширить существующий:
```python
class CustomIPRestrictionMiddleware:
"""Ограничение по IP для произвольных URL"""
RESTRICTED_PATHS = (
"/admin/", # Например, админка только из локальной сети
"/api/v1/sensitive/", # Чувствительные API
)
def __call__(self, request):
from common.ip_restrictions import get_client_ip, is_ip_allowed
path = request.path_info
if any(path.startswith(p) for p in self.RESTRICTED_PATHS):
client_ip = get_client_ip(request)
if not is_ip_allowed(client_ip):
# Блокируем доступ
...
return self.get_response(request)
```
## Отключение ограничений
### Временное отключение (для разработки)
В `settings.py`:
```python
REGISTRATION_ALLOWED_IPS = ['*'] # Разрешить все IP
```
### Полное отключение
В `settings.py` закомментируйте middleware:
```python
MIDDLEWARE = [
# ... другие middleware ...
# "eusrr_backend.middleware.RegistrationIPRestrictionMiddleware", # Отключено
"eusrr_backend.middleware.CacheControlMiddleware",
]
```
## Мониторинг
### Добавление логирования
Можно расширить `backend/eusrr_backend/middleware.py`:
```python
def __call__(self, request):
import logging
logger = logging.getLogger(__name__)
path = request.path_info
if not any(path.startswith(p) for p in self.RESTRICTED_PATHS):
return self.get_response(request)
client_ip = get_client_ip(request)
if not is_ip_allowed(client_ip):
logger.warning(
f'Registration blocked for IP {client_ip} '
f'(path: {path}, user-agent: {request.META.get("HTTP_USER_AGENT")})'
)
# ... блокировка ...
logger.info(f'Registration allowed for IP {client_ip}')
return self.get_response(request)
```
### Метрики
Можно отслеживать:
- Количество заблокированных попыток регистрации
- IP-адреса, которые часто блокируются
- Географию заблокированных запросов
## Вопросы и поддержка
При возникновении проблем проверьте:
1. Правильность настройки `REGISTRATION_ALLOWED_IPS` в settings.py
2. Корректность заголовков прокси (если используется)
3. Middleware включен в `MIDDLEWARE` в settings.py
4. Формат IP-адресов в конфигурации (должны быть валидными CIDR или IP)
5. Логи Django на наличие ошибок в модуле `eusrr_backend.middleware`

96
IP_RESTRICTIONS_README.md Normal file
View File

@ -0,0 +1,96 @@
# 🔒 IP-ограничения для регистрации - Краткая инструкция
## ✅ Что сделано
Добавлен **middleware** для ограничения доступа к регистрации по IP-адресу.
## 📁 Изменённые файлы
1. **`backend/eusrr_backend/middleware.py`** - добавлен `RegistrationIPRestrictionMiddleware`
2. **`backend/eusrr_backend/settings.py`** - middleware включен в `MIDDLEWARE`
3. **`backend/common/ip_restrictions.py`** - утилиты для проверки IP
## ⚙️ Настройка
В `backend/eusrr_backend/settings.py` найдите:
```python
# IP ОГРАНИЧЕНИЯ ДЛЯ РЕГИСТРАЦИИ
REGISTRATION_ALLOWED_IPS = None # По умолчанию только локальные IP
```
### Варианты настройки:
```python
# 1. Только локальные IP (по умолчанию)
REGISTRATION_ALLOWED_IPS = None
# 2. Разрешить все IP (для разработки)
REGISTRATION_ALLOWED_IPS = ['*']
# 3. Разрешить конкретные сети
REGISTRATION_ALLOWED_IPS = [
'192.168.1.0/24', # Локальная сеть офиса
'10.0.0.0/8', # Корпоративная сеть
]
# 4. Разрешить конкретные IP
REGISTRATION_ALLOWED_IPS = ['192.168.1.100', '203.0.113.42']
```
## 🎯 Как работает
**По умолчанию (None):**
- ✅ Разрешены: `127.0.0.1`, `192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`
- ❌ Заблокированы: все внешние IP
**Блокируемые URL:**
- `/auth/register/` (веб-форма)
- `/api/v1/auth/register/` (API)
**Ответ при блокировке:**
- API: `{"detail": "Регистрация доступна только из локальной сети.", "client_ip": "..."}`
- Веб: Красивая страница 403 с информацией об IP
## 🔧 Отключение
### Временно (для разработки):
```python
REGISTRATION_ALLOWED_IPS = ['*']
```
### Полностью:
В `settings.py` закомментируйте строку в `MIDDLEWARE`:
```python
# "eusrr_backend.middleware.RegistrationIPRestrictionMiddleware",
```
## 🧪 Тестирование
```bash
# Из локальной сети - работает
curl http://localhost:9000/api/v1/auth/register/ \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com",...}'
# С внешнего IP - блокируется
curl http://localhost:9000/api/v1/auth/register/ \
-H "X-Forwarded-For: 203.0.113.50" \
-d '{"email":"test@example.com",...}'
```
## 🌐 Работа за прокси
Middleware автоматически определяет реальный IP через заголовки `X-Forwarded-For` и `X-Real-IP`.
Настройте Nginx:
```nginx
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
```
## 📚 Полная документация
См. `IP_REGISTRATION_RESTRICTIONS.md`

View File

@ -0,0 +1,152 @@
"""
Ограничение доступа к определенным функциям по IP-адресу.
"""
import ipaddress
from functools import wraps
from typing import List
from django.conf import settings
from django.http import HttpResponse
def get_client_ip(request) -> str:
"""
Получает реальный IP-адрес клиента из request.
Учитывает заголовки прокси (X-Forwarded-For, X-Real-IP).
"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
# Берем первый IP из списка (клиентский IP)
ip = x_forwarded_for.split(',')[0].strip()
else:
ip = request.META.get('REMOTE_ADDR', '')
return ip
def is_local_ip(ip_address: str) -> bool:
"""
Проверяет, является ли IP-адрес локальным.
Локальными считаются:
- 127.0.0.0/8 (localhost)
- 10.0.0.0/8 (частная сеть класса A)
- 172.16.0.0/12 (частная сеть класса B)
- 192.168.0.0/16 (частная сеть класса C)
- ::1 (IPv6 localhost)
- fe80::/10 (IPv6 link-local)
"""
try:
ip = ipaddress.ip_address(ip_address)
return ip.is_private or ip.is_loopback or ip.is_link_local
except ValueError:
# Невалидный IP - считаем небезопасным
return False
def is_ip_allowed(ip_address: str, allowed_networks: List[str] = None) -> bool:
"""
Проверяет, разрешен ли доступ с данного IP-адреса.
Args:
ip_address: IP-адрес для проверки
allowed_networks: Список разрешенных сетей в формате CIDR
или отдельных IP. Если None, используется
REGISTRATION_ALLOWED_IPS из settings.
Returns:
True если IP разрешен, False иначе
"""
if allowed_networks is None:
# Проверяем настройки Django
allowed_networks = getattr(settings, 'REGISTRATION_ALLOWED_IPS', None)
# Если настройка не задана, разрешаем только локальные IP
if allowed_networks is None:
return is_local_ip(ip_address)
# Пустой список = запретить всем
if not allowed_networks:
return False
# Специальное значение для разрешения всех IP
if allowed_networks == ['*'] or '*' in allowed_networks:
return True
try:
ip = ipaddress.ip_address(ip_address)
for network_str in allowed_networks:
# Проверяем, является ли это сетью (содержит /) или отдельным IP
if '/' in network_str:
network = ipaddress.ip_network(network_str, strict=False)
if ip in network:
return True
else:
# Отдельный IP-адрес
allowed_ip = ipaddress.ip_address(network_str)
if ip == allowed_ip:
return True
return False
except ValueError:
# Невалидный IP - запрещаем
return False
def local_ip_required(view_func):
"""
Декоратор для ограничения доступа к view только с локальных IP.
Использование:
@local_ip_required
def my_view(request):
...
"""
@wraps(view_func)
def wrapper(request, *args, **kwargs):
client_ip = get_client_ip(request)
if not is_ip_allowed(client_ip):
return HttpResponse(
'<h1>403 Forbidden</h1>'
'<p>Регистрация доступна только из локальной сети.</p>'
f'<p>Ваш IP: {client_ip}</p>',
status=403,
content_type='text/html; charset=utf-8'
)
return view_func(request, *args, **kwargs)
return wrapper
def local_ip_required_api(view_class):
"""
Декоратор для ограничения доступа к API View только с локальных IP.
Использование:
@local_ip_required_api
class MyAPIView(APIView):
...
"""
original_dispatch = view_class.dispatch
def dispatch_wrapper(self, request, *args, **kwargs):
from rest_framework.response import Response
from rest_framework import status as drf_status
client_ip = get_client_ip(request)
if not is_ip_allowed(client_ip):
return Response(
{
'detail': 'Регистрация доступна только из локальной сети.',
'client_ip': client_ip
},
status=drf_status.HTTP_403_FORBIDDEN
)
return original_dispatch(self, request, *args, **kwargs)
view_class.dispatch = dispatch_wrapper
return view_class

View File

@ -140,6 +140,143 @@ class EmailVerificationMiddleware:
return self.get_response(request)
class RegistrationIPRestrictionMiddleware:
"""
Проверяет IP-адрес для запросов к регистрации.
Применяется только к URL-ам регистрации:
- /auth/register/
- /api/v1/auth/register/
Все остальные URL пропускаются без проверки.
"""
RESTRICTED_PATHS = (
"/auth/register/",
"/api/v1/auth/register/",
)
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
from common.ip_restrictions import get_client_ip, is_ip_allowed
path = request.path_info
# Проверяем только запросы к регистрации
if not any(path.startswith(p) for p in self.RESTRICTED_PATHS):
return self.get_response(request)
# Проверяем IP-адрес
client_ip = get_client_ip(request)
if not is_ip_allowed(client_ip):
# IP не разрешен - блокируем
# Для API возвращаем JSON
if path.startswith("/api/"):
from django.http import JsonResponse
return JsonResponse(
{
"detail": "Регистрация доступна только из "
"локальной сети.",
"client_ip": client_ip
},
status=403
)
# Для веб-страниц возвращаем HTML
from django.http import HttpResponse
return HttpResponse(
f"""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,
initial-scale=1.0">
<title>403 Forbidden</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg,
#667eea 0%, #764ba2 100%);
}}
.container {{
background: white;
padding: 3rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
max-width: 500px;
text-align: center;
}}
h1 {{
color: #e53e3e;
font-size: 3rem;
margin: 0 0 1rem 0;
}}
h2 {{
color: #2d3748;
font-size: 1.5rem;
margin: 0 0 1.5rem 0;
}}
p {{
color: #4a5568;
line-height: 1.6;
margin: 0 0 1rem 0;
}}
.ip-info {{
background: #f7fafc;
border-left: 4px solid #667eea;
padding: 1rem;
margin: 1.5rem 0;
border-radius: 0.5rem;
font-family: monospace;
}}
.back-link {{
display: inline-block;
margin-top: 1.5rem;
padding: 0.75rem 2rem;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 0.5rem;
}}
.back-link:hover {{
background: #5a67d8;
}}
</style>
</head>
<body>
<div class="container">
<h1>🔒 403</h1>
<h2>Доступ запрещен</h2>
<p>Регистрация доступна только из локальной сети.</p>
<p>Если вы в офисе или подключены через VPN,
обратитесь к администратору.</p>
<div class="ip-info">
Ваш IP: <strong>{client_ip}</strong>
</div>
<a href="/" class="back-link">← На главную</a>
</div>
</body>
</html>
""",
status=403,
content_type="text/html; charset=utf-8"
)
# IP разрешен - продолжаем обработку
return self.get_response(request)
class CacheControlMiddleware:
"""
Добавляет правильные заголовки Cache-Control для страниц.

View File

@ -62,6 +62,7 @@ MIDDLEWARE = [
"api.middleware.JWTRefreshMiddleware", # Автообновление JWT токенов
"eusrr_backend.middleware.AuthRequiredMiddleware",
"eusrr_backend.middleware.EmailVerificationMiddleware",
"eusrr_backend.middleware.RegistrationIPRestrictionMiddleware", # IP ограничение для регистрации
"eusrr_backend.middleware.CacheControlMiddleware", # Cache-Control headers
]
@ -157,6 +158,17 @@ PASSWORD_RESET_TIMEOUT = 60 * 60 * 24
PHONE_DEFAULT_REGION = os.getenv("PHONE_DEFAULT_REGION", "RU")
PHONENUMBER_DEFAULT_REGION = "RU"
# -----------------------------------------------------------------------------
# IP ОГРАНИЧЕНИЯ ДЛЯ РЕГИСТРАЦИИ
# -----------------------------------------------------------------------------
# Список разрешенных IP-адресов или сетей для регистрации (формат CIDR)
# Примеры:
# None - разрешены только локальные IP (по умолчанию)
# ['*'] - разрешены все IP
# ['192.168.1.0/24', '10.0.0.0/8'] - конкретные сети
# ['192.168.1.100', '192.168.1.101'] - конкретные IP
REGISTRATION_ALLOWED_IPS = None # По умолчанию только локальные IP
# Медиа файлы
MEDIA_ROOT = os.path.join(BASE_DIR, "media/")
MEDIA_URL = "/media/"