Урок 41. Автентифікація та security basics (JWT, hashing, OWASP)¶
Django-гілка курсу веде той самий застосунок нотаток з уроку 34: сторінки з формами (34), JSON API на DRF (35). Вхід на сайт і правило «кожен бачить лише свої нотатки» вже є — login_required, user=request.user, чужа нотатка → 404. Сьогодні рефакторинг 4 — безпека:
- спільний доступ: нотатка належить групі («Сім'я», «Команда») — і тоді вже не можна просто писати
user=request.user; - паролі: як Django їх зберігає, зміна й скидання пароля листом;
- JWT для API — вхід для програм, яким не підходить cookie браузера;
- налаштування безпеки і
check --deploy; карта ризиків OWASP Top 10 на нашому коді.
Стартовий код — той самий crispy_notes_project, до якого додано групи, скидання пароля й блок налаштувань безпеки. Теорія — частина VII Django-книги: тут лише зміни в коді й те, що знайшли, коли зібрали все разом.
| Урок | Django-гілка: застосунок нотаток | Проєкт |
|---|---|---|
| 33 | MVT, ORM, admin | hello_project |
| 34 | форми, Bootstrap, crispy | crispy_notes_project |
| 35 | REST API на DRF | + api.py |
| 40 | групи, паролі, JWT, налаштування безпеки | + групи, /api/token/ |
| 44 | архітектура: services і selectors | — |
| 45 | чат на WebSocket | — |
Проєкт: crispy_notes_project.
Що потрібно з попередніх уроків: застосунок нотаток і його services/selectors (33–35), DRF-серіалізатори й ViewSet (35), HTTP-заголовки й статус-коди (31–32), хеш-функції (урок 17).
Після уроку ти зможеш:
- відрізнити автентифікацію («хто ти») від авторизації («що тобі можна») і перевірити обидві на кожному вході в застосунок;
- дати доступ до об'єкта групі користувачів і розділити права «читати» і «змінювати»;
- пояснити, як Django зберігає пароль і чому той самий пароль дає різні хеші;
- видати й перевірити JWT, пояснити, з чого складається токен і чому його не можна «підправити»;
- прочитати
manage.py check --deployі зв'язати знахідки з OWASP Top 10.
Ноутбук заняття: Відкрити вправи в Colab Переглянути розв’язки — хеші паролів, JWT по частинах, групи й права в API.
Пригадай¶
- Що поверне API уроку 36 на запит чужої нотатки і чому не
403? - Хеш-функція з уроку 17: чи можна з хешу отримати вхідні дані?
- Де браузер зберігає «я увійшов» між запитами до Django?
Відповіді
404: для чужого користувача нотатки «немає».403підтвердив би, що нотатка з таким id існує, — це вже витік.- Ні: хеш — односторонній. Можна лише порахувати хеш іншого рядка й порівняти.
- У cookie
sessionid: у ній лише випадковий ключ, а дані сесії (хто увійшов) — на сервері, в таблиціdjango_session. Детально — Сесії в Django-книзі.
Старт: з якого коду починаємо¶
Стартовий crispy_notes_project — той самий проєкт, що в уроці 35, плюс:
| Файл | Що змінилось у стартовому коді |
|---|---|
models.py, міграція 0003 |
Note.group, ShoppingList.group → ForeignKey(Group, SET_NULL, null=True) |
selectors.py |
нотатки й списки: Q(user=user) \| Q(group__in=user.groups.all()); вибір груп користувача |
views.py |
редагувати й видаляти — лише автор (повідомлення й redirect для учасника групи); сторінки груп |
forms.py |
поле group — лише свої групи (user.groups.all()); форми створення групи й додавання учасника |
services.py |
create_group, add_user_to_group, remove_user_from_group; group у create/update |
templates/registration/ |
7 шаблонів зміни й скидання пароля |
settings.py |
EMAIL_BACKEND (console), SESSION_COOKIE_HTTPONLY, SAMESITE, X_FRAME_OPTIONS, SECURE_CONTENT_TYPE_NOSNIFF |
Ці зміни перенесені на проєкт уроку 36 (з API) без змін логіки; конфлікт був лише в create_note — там наш параметр is_pinned з уроку 35.
Рефакторинг 4a. Нотатки групи¶
Коли нотатку бачить не лише автор, правило «user=request.user» розпадається на два:
| Хто | Бачить | Змінює й видаляє |
|---|---|---|
| автор | так | так |
| учасник групи нотатки | так | ні |
| інший користувач | ні — 404 |
ні — 404 |
def get_user_notes(user, archived=False, notebook=None, search=None):
+ user_groups = user.groups.all()
qs = Note.objects.filter(
- user=user, is_archived=archived
- ).select_related('notebook').prefetch_related('tags')
+ Q(user=user) | Q(group__in=user_groups), is_archived=archived
+ ).select_related('notebook', 'group').prefetch_related('tags')
- note = get_object_or_404(Note, pk=pk, user=request.user)
+ user_groups = request.user.groups.all()
+ note = get_object_or_404(
+ Note.objects.filter(Q(user=request.user) | Q(group__in=user_groups)),
+ pk=pk,
+ )
+ if note.user != request.user:
+ messages.error(request, 'Ти не можеш редагувати нотатку іншого користувача.')
+ return redirect('hello_app:note_detail', pk=pk)
Перевіримо в manage.py shell (у папці crispy_notes_project): Олена ділиться нотаткою з сім'єю, Тарас — у сім'ї, Іван — ні.
from django.contrib.auth.models import User
from django.test.utils import setup_test_environment
from rest_framework.test import APIClient
from hello_app import services
from hello_app.models import Note
setup_test_environment()
olena = User.objects.create_user("olena", email="olena@example.com", password="Sup3r-secret!")
taras = User.objects.create_user("taras", password="Sup3r-secret!")
ivan = User.objects.create_user("ivan", password="Sup3r-secret!")
family = services.create_group(name="Сім'я", creator=olena)
family.user_set.add(taras)
wifi = services.create_note(user=olena, title="Пароль від Wi-Fi", group=family)
services.create_note(user=olena, title="Подарунок Тарасу") # особиста
from django.test import Client
for user in (olena, taras, ivan):
browser = Client()
browser.force_login(user)
page = browser.get("/notes/").content.decode()
visible = [title for title in ("Пароль від Wi-Fi", "Подарунок Тарасу") if title in page]
print(f"{user.username:6} бачить {visible}; сторінка нотатки → {browser.get(f'/notes/{wifi.pk}/').status_code}")
browser = Client()
browser.force_login(taras)
browser.post(f"/notes/{wifi.pk}/delete/")
print("Тарас видаляє на сайті → нотатка на місці:", Note.objects.filter(pk=wifi.pk).exists())
olena бачить ['Пароль від Wi-Fi', 'Подарунок Тарасу']; сторінка нотатки → 200
taras бачить ['Пароль від Wi-Fi']; сторінка нотатки → 200
Not Found: /notes/1/
ivan бачить []; сторінка нотатки → 404
Тарас видаляє на сайті → нотатка на місці: True
Права в API: групи відкривають ще один вхід¶
Сторінки перевіряють note.user != request.user. А API уроку 36 бере нотатку тим самим селектором get_note_detail, який тепер віддає й нотатки групи. Якщо в API не додати перевірку автора, та сама спроба, що вище, через API дає:
Тарас через API: PATCH → 200, заголовок став «Зламано учасником групи»
Тарас через API: DELETE → 204, нотатки більше немає
Класична Broken Access Control (OWASP A01, перше місце в рейтингу): доступ розширили, а один із входів у застосунок забули оновити. HTML і API — два входи до тих самих даних, і перевіряти права треба на кожному.
def _get_note(self, request, pk):
"""Читати: свою нотатку або нотатку своєї групи (урок 41). Чужа — 404, ніби її немає."""
try:
return selectors.get_note_detail(request.user, pk)
except Note.DoesNotExist:
raise NotFound("Нотатку не знайдено.")
def _get_own_note(self, request, pk):
"""Змінювати й видаляти: лише автор. Нотатка групи видима учаснику, але не його — 403."""
note = self._get_note(request, pk)
if note.user_id != request.user.id:
raise PermissionDenied("Змінювати й видаляти нотатку може лише її автор.")
return note
partial_update, destroy і pin тепер беруть _get_own_note:
api = APIClient()
api.force_authenticate(taras)
print("Тарас читає →", api.get(f"/api/notes/{wifi.pk}/").status_code)
print("Тарас змінює →", api.patch(f"/api/notes/{wifi.pk}/", {"title": "Зламано учасником групи"}, format="json").status_code)
print("Тарас видаляє →", api.delete(f"/api/notes/{wifi.pk}/").status_code, api.delete(f"/api/notes/{wifi.pk}/").data["detail"])
api.force_authenticate(ivan)
print("Іван читає →", api.get(f"/api/notes/{wifi.pk}/").status_code)
api.force_authenticate(olena)
print("Олена змінює →", api.patch(f"/api/notes/{wifi.pk}/", {"title": "Пароль від Wi-Fi (новий)"}, format="json").status_code)
Тарас читає → 200
Forbidden: /api/notes/1/
Тарас змінює → 403
Forbidden: /api/notes/1/
Forbidden: /api/notes/1/
Тарас видаляє → 403 Змінювати й видаляти нотатку може лише її автор.
Not Found: /api/notes/1/
Іван читає → 404
Олена змінює → 200
403 для учасника групи чесний: нотатку він і так бачить, приховувати нічого. Для Івана — 404, як і в уроці 36. Тест test_api_member_reads_but_cannot_write закріплює правило: без _get_own_note він падає.
Поглиблено: Права доступу, Автентифікація, сесії й права: IDOR.
Паролі¶
Django ніколи не зберігає пароль. У полі password — рядок алгоритм$ітерації$сіль$хеш:
from django.contrib.auth.hashers import check_password, make_password
import time
print(olena.password)
print(taras.password)
start = time.perf_counter()
make_password("Sup3r-secret!")
print(f"один хеш: {time.perf_counter() - start:.2f} с")
print(check_password("Sup3r-secret!", olena.password), check_password("sup3r-secret!", olena.password))
Приклад виводу (сіль і хеш — випадкові, час залежить від машини):
pbkdf2_sha256$1000000$gr731R4wX4noyVbhCAb0FV$mFZOgLHmvZIKdBWR/2EiLKZJQglRYEYI14GH5U5w+XU=
pbkdf2_sha256$1000000$p5KcrASXd5UWpKxRZmsqbM$NLrWG1boJBX2fr7diWhStJRxl38NP2qG+zl5Mxez92Y=
один хеш: 0.79 с
True False
- Той самий пароль — різні рядки. Сіль — випадкова для кожного користувача, тож однакові паролі не видно з бази, а заздалегідь пораховані таблиці хешів (rainbow tables) марні.
- Навмисно повільно. PBKDF2 повторює хеш-функцію мільйон разів (у Django 5.2 —
1000000, число записане в рядку, тож його можна збільшувати з версіями). Для входу одна перевірка — непомітно, для перебору мільйонів паролів після витоку бази — роки. check_passwordрахує хеш введеного пароля з тією ж сіллю й порівнює. Зворотного шляху немає.
Зміна й скидання пароля¶
path("accounts/", include("django.contrib.auth.urls")) уже дає всі сторінки; стартовий код додав 7 шаблонів у templates/registration/ і EMAIL_BACKEND = "…console.EmailBackend" — лист друкується в термінал runserver замість справжньої пошти.
sequenceDiagram
participant U as Користувач
participant D as Django
participant M as Пошта (console)
U->>D: POST /accounts/password_reset/ email
D->>D: є користувач з таким email?
D->>M: так — лист з посиланням /accounts/reset/uid/token/
D-->>U: 302 → «лист надіслано» (однаково, є email чи ні)
U->>D: GET посилання з листа
D->>D: токен: підпис SECRET_KEY + хеш пароля + час
D-->>U: форма нового пароля
U->>D: POST новий пароль
D->>D: set_password → новий хеш → старий токен більше не діє
from django.core import mail
for email in ("olena@example.com", "nobody@example.com"):
response = Client().post("/accounts/password_reset/", {"email": email})
print(f"{email:20} → {response.status_code} {response['Location']}")
print("листів:", len(mail.outbox), "| кому:", mail.outbox[0].to)
link = next(line for line in mail.outbox[0].body.splitlines() if "/accounts/reset/" in line)
print("посилання:", link.strip().rsplit("/", 3)[0] + "/…/")
olena@example.com → 302 /accounts/password_reset/done/
nobody@example.com → 302 /accounts/password_reset/done/
листів: 1 | кому: ['olena@example.com']
посилання: http://testserver/accounts/reset/…/
Однакова відповідь для відомого й невідомого email — не випадковість: інакше форма скидання стала б способом перевіряти, хто зареєстрований (user enumeration, OWASP A07). Токен у посиланні одноразовий: він залежить від хешу пароля, тож після зміни пароля старе посилання не спрацює.
Поглиблено: Автентифікація, Основи безпеки.
Рефакторинг 4b. JWT для API¶
Сесія (cookie + CSRF) зручна браузеру на тому самому сайті. Мобільному застосунку, скрипту чи Telegram-боту (урок 48) потрібне інше: отримати токен і надсилати його в заголовку Authorization: Bearer …. У прототипі production_bot токени видавались вручну через PyJWT; у Django — готовий пакет djangorestframework-simplejwt.
| Сесія (cookie) | JWT (Bearer) | |
|---|---|---|
| Де «я увійшов» | ключ у cookie, дані — на сервері (django_session) |
усе в самому токені, підписаному SECRET_KEY |
| Хто надсилає | браузер сам, з кожним запитом | клієнт явно, в заголовку |
| CSRF | потрібен захист | не потрібен (браузер сам заголовок не додає) |
| Вийти / відкликати | видалити сесію на сервері | токен діє до exp; тому access-токен короткий |
| Кому | сайт | мобільний застосунок, скрипт, бот, інший сервіс |
REST_FRAMEWORK = {
+ # Порядок важливий: без облікових даних DRF відповідає за ПЕРШИМ класом. JWT першим → 401 з
+ # WWW-Authenticate: Bearer; сесія першою → 403 (у неї немає заголовка WWW-Authenticate).
"DEFAULT_AUTHENTICATION_CLASSES": [
+ "rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
- "rest_framework.authentication.BasicAuthentication",
],
...
+ "DEFAULT_THROTTLE_RATES": {"login": "5/min"},
}
+
+SIMPLE_JWT = {
+ "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
+ "REFRESH_TOKEN_LIFETIME": timedelta(days=1),
+ "ROTATE_REFRESH_TOKENS": True,
+ "AUTH_HEADER_TYPES": ("Bearer",),
+}
BasicAuthenticationприбрано: вона надсилає логін і пароль з кожним запитом.- Access живе 5 хвилин — викрадений токен швидко «згорає»; refresh (доба) обмінюють на новий access без пароля.
/api/token/— видача пари токенів, з throttle 5 спроб за хвилину (ScopedRateThrottle, файлhello_app/auth_api.py);/api/token/refresh/— оновлення.
import base64
import json
api = APIClient()
r = api.get("/api/notes/")
print("без токена →", r.status_code, r["WWW-Authenticate"])
tokens = api.post("/api/token/", {"username": "olena", "password": "Sup3r-secret!"}, format="json").json()
access = tokens["access"]
print("токен →", access[:40] + "…", "| частин:", len(access.split(".")))
header, payload, signature = access.split(".")
decode = lambda part: json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)))
print("header →", decode(header))
claims = decode(payload)
print("payload →", {key: claims[key] for key in ("token_type", "user_id")}, "| живе", claims["exp"] - claims["iat"], "с")
api.credentials(HTTP_AUTHORIZATION=f"Bearer {access}")
print("з токеном →", api.get("/api/notes/").status_code, [n["title"] for n in api.get("/api/notes/").json()])
Unauthorized: /api/notes/
без токена → 401 Bearer realm="api"
токен → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ… | частин: 3
header → {'alg': 'HS256', 'typ': 'JWT'}
payload → {'token_type': 'access', 'user_id': '1'} | живе 300 с
з токеном → 200 ['Пароль від Wi-Fi (новий)', 'Подарунок Тарасу']
Payload не зашифрований — його прочитає будь-хто (ми щойно прочитали без жодного ключа). Тому в токен не кладуть нічого секретного. Захищає його підпис.
Як сервер перевіряє токен — покроково¶
flowchart TD
classDef step fill:#eceff1,stroke:#546e7a,stroke-width:1px,color:#1a1a1a;
classDef decision fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#1a1a1a;
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a;
classDef error fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#1a1a1a;
classDef warning fill:#fff8e1,stroke:#e65100,stroke-width:2px,color:#1a1a1a;
subgraph S1 ["1. розібрати заголовок"]
direction LR
a1["Authorization: Bearer xxx.yyy.zzz"] --> a2["header . payload . signature"]
end
subgraph S2 ["2. перевірити підпис"]
direction LR
b1["HMAC-SHA256(header.payload,<br>SECRET_KEY)"] --> b2{"= signature?"}
b2 -- ні --> b3["401: підправлений<br>або чужий ключ"]
end
subgraph S3 ["3. перевірити час"]
direction LR
c1{"exp > зараз?"} -- ні --> c2["401: токен<br>прострочений"]
end
subgraph S4 ["4. знайти користувача"]
direction LR
d1["user_id з payload"] --> d2{"активний?"} -- так --> d3["request.user = olena"]
end
S1 --> S2 --> S3 --> S4
class a1,a2,b1,d1 step
class b2,c1,d2 decision
class b3,c2 error
class d3 success
Перевіримо кожну гілку: підправлений підпис, підпис чужим ключем, прострочений токен і оновлення через refresh:
from datetime import timedelta
import jwt
from rest_framework_simplejwt.tokens import AccessToken
tampered = f"{header}.{payload}.{signature[:-4]}AAAA"
foreign = jwt.encode({**claims, "user_id": str(ivan.id)}, "зовсім-інший-ключ-підпису-довжиною-понад-32-байти",
algorithm="HS256")
expired = AccessToken.for_user(olena)
expired.set_exp(lifetime=-timedelta(seconds=1))
for name, token in (("підправлений", tampered), ("чужий ключ", foreign), ("прострочений", str(expired))):
api.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = api.get("/api/notes/")
print(f"{name:13} → {response.status_code} {response.json()['detail']}")
api.credentials()
fresh = api.post("/api/token/refresh/", {"refresh": tokens["refresh"]}, format="json").json()
print("refresh →", sorted(fresh), "| новий refresh:", fresh["refresh"] != tokens["refresh"])
Unauthorized: /api/notes/
підправлений → 401 Given token not valid for any token type
Unauthorized: /api/notes/
чужий ключ → 401 Given token not valid for any token type
Unauthorized: /api/notes/
прострочений → 401 Given token not valid for any token type
refresh → ['access', 'refresh'] | новий refresh: True
Усі три відмови — з тим самим повідомленням: сервер не підказує, яка саме перевірка не пройшла. ROTATE_REFRESH_TOKENS — з кожним оновленням новий refresh-токен. Відкликати вже видані токени (кнопка «вийти на всіх пристроях») дає додаток token_blacklist — див. «Спробуй самостійно».
Перебір паролів: throttle¶
from django.core.cache import cache
cache.clear() # лічильник спроб живе в кеші; вхід з попереднього кроку теж рахувався
api = APIClient()
statuses = [api.post("/api/token/", {"username": "olena", "password": f"guess-{n}"}, format="json").status_code
for n in range(6)]
print("6 невдалих спроб →", statuses)
right = api.post("/api/token/", {"username": "olena", "password": "Sup3r-secret!"}, format="json")
print("правильний пароль →", right.status_code, right["Retry-After"], "с")
Unauthorized: /api/token/
Unauthorized: /api/token/
Unauthorized: /api/token/
Unauthorized: /api/token/
Unauthorized: /api/token/
Too Many Requests: /api/token/
6 невдалих спроб → [401, 401, 401, 401, 401, 429]
Too Many Requests: /api/token/
правильний пароль → 429 57 с
Після 5 спроб за хвилину — 429, навіть з правильним паролем: зловмисник не може перебирати швидко, а справжній користувач чекає хвилину. Той самий принцип, що rate limit агрегатора в уроці 40, — тут готовий ScopedRateThrottle DRF.
Поглиблено: DRF: автентифікація й permissions у Django-книзі.
Налаштування безпеки і check --deploy¶
Стартовий код додав блок налаштувань; урок 41 — SECRET_KEY, DEBUG і ALLOWED_HOSTS зі змінних середовища:
# Цим ключем підписуються сесії, токени скидання пароля і JWT. Хто його знає — підробить будь-який токен.
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-crispy-notes-dev-key-change-in-production")
DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1"
ALLOWED_HOSTS = [host for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if host]
SESSION_COOKIE_HTTPONLY = True # JavaScript не прочитає cookie сесії (XSS не вкраде вхід)
SESSION_COOKIE_SAMESITE = "Lax" # cookie не йде з чужих сайтів (частина захисту від CSRF)
X_FRAME_OPTIONS = "DENY" # сторінку не вставиш в <iframe> (clickjacking)
SECURE_CONTENT_TYPE_NOSNIFF = True # браузер не вгадує тип файлу
Django сам перевіряє, що ще не готово до сервера:
$ python manage.py check --deploy
System check identified some issues:
WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. If your entire site is served only over SSL, you may want to consider setting a value and enabling HTTP Strict Transport Security. Be sure to read the documentation first; enabling HSTS carelessly can cause serious, irreversible problems.
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. Unless your site should be available over both SSL and non-SSL connections, you may want to either set this setting True or configure a load balancer or reverse-proxy server to redirect all connections to HTTPS.
?: (security.W009) Your SECRET_KEY has less than 50 characters, less than 5 unique characters, or it's prefixed with 'django-insecure-' indicating that it was generated automatically by Django. Please generate a long and random value, otherwise many of Django's security-critical features will be vulnerable to attack.
?: (security.W012) SESSION_COOKIE_SECURE is not set to True. Using a secure-only session cookie makes it more difficult for network traffic sniffers to hijack user sessions.
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True. Using a secure-only CSRF cookie makes it more difficult for network traffic sniffers to steal the CSRF token.
?: (security.W018) You should not have DEBUG set to True in deployment.
?: (security.W020) ALLOWED_HOSTS must not be empty in deployment.
System check identified 7 issues (0 silenced).
Кожен рядок — готовий пункт списку перед деплоєм: W009 — ключ з префіксом django-insecure-, W018 — DEBUG = True, W020 — порожній ALLOWED_HOSTS, решта — HTTPS (SECURE_HSTS_SECONDS, SECURE_SSL_REDIRECT, *_COOKIE_SECURE), які вмикають на сервері з сертифікатом (урок 50). Ті самі налаштування через змінні середовища:
$ DJANGO_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(50))')" DJANGO_DEBUG=0 DJANGO_ALLOWED_HOSTS=notes.example.com python manage.py check --deploy
System check identified some issues:
WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. If your entire site is served only over SSL, you may want to consider setting a value and enabling HTTP Strict Transport Security. Be sure to read the documentation first; enabling HSTS carelessly can cause serious, irreversible problems.
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. Unless your site should be available over both SSL and non-SSL connections, you may want to either set this setting True or configure a load balancer or reverse-proxy server to redirect all connections to HTTPS.
?: (security.W012) SESSION_COOKIE_SECURE is not set to True. Using a secure-only session cookie makes it more difficult for network traffic sniffers to hijack user sessions.
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True. Using a secure-only CSRF cookie makes it more difficult for network traffic sniffers to steal the CSRF token.
System check identified 4 issues (0 silenced).
Лишились лише попередження про HTTPS — їх закриває урок 50.
OWASP Top 10 на нашому коді¶
OWASP Top 10 — рейтинг найпоширеніших ризиків вебзастосунків. Де вони в застосунку нотаток:
| Ризик | Де в проєкті | Що захищає |
|---|---|---|
| A01 Broken Access Control | чужа нотатка; нотатка групи в API | 404 для чужих (35), _get_own_note → 403 для учасників (40), групи у формі — лише свої |
| A02 Cryptographic Failures | паролі, SECRET_KEY |
PBKDF2 з сіллю; ключ зі змінної середовища |
| A03 Injection | фільтри й пошук | ORM передає значення параметрами (урок 30); автоекранування шаблонів |
| A05 Security Misconfiguration | DEBUG, ALLOWED_HOSTS, заголовки |
check --deploy, блок налаштувань безпеки |
| A07 Identification and Authentication Failures | вхід, скидання пароля, API | throttle на /api/token/, однакова відповідь для невідомого email, короткий access-токен, валідатори пароля |
Решта пунктів (A04, A06, A08–A10) і розбір кожного — OWASP Top 10 у Django-книзі, а поглиблено — урок 47.
Архітектура: два входи, одні правила¶
flowchart TD
classDef step fill:#eceff1,stroke:#546e7a,stroke-width:1px,color:#1a1a1a;
classDef decision fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#1a1a1a;
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a;
classDef error fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#1a1a1a;
classDef warning fill:#fff8e1,stroke:#e65100,stroke-width:2px,color:#1a1a1a;
B["браузер<br>cookie sessionid + CSRF"] --> V["views.py<br>login_required"]
C["застосунок, скрипт, бот<br>Authorization: Bearer"] --> A["api.py<br>JWT, потім сесія"]
T["POST /api/token/<br>throttle 5/хв"] -. "access + refresh" .-> C
V --> R["selectors<br>свої + нотатки груп"]
A --> R
V --> W{"змінює?<br>автор?"}
A --> W
W -- ні --> X["сайт: повідомлення<br>API: 403"]
W -- так --> S["services<br>update / delete"]
S --> DB[("база")]
R --> DB
class B,C,V,A,R step
class T,W warning
class X error
class S,DB success
- Автентифікація ≠ авторизація. Сесія чи JWT відповідають лише на «хто ти». «Що тобі можна» вирішує код: selectors (що бачиш) і перевірка автора (що змінюєш).
- Правила — на кожному вході. Сайт і API читають тими самими selectors, тож перевірка автора потрібна на обох. Правило, яке треба пам'ятати у двох місцях, колись забудуть — у практиці його винесемо в один DRF-permission.
- Секрет — один.
SECRET_KEYпідписує сесії, токени скидання й JWT. Він — у змінній середовища, не в git.
Тести¶
Приклад виводу (час залежить від машини; хешування паролів навмисно повільне):
$ python manage.py test
Found 29 test(s).
System check identified no issues (0 silenced).
Creating test database for alias 'default'...
.............................
----------------------------------------------------------------------
Ran 29 tests in 51.170s
OK
Destroying test database for alias 'default'...
hello_app/tests_auth.py — 13 тестів: видимість нотаток групи на сайті, заборона змін для учасника на сайті й в API (403) і 404 для стороннього, групи у формі — лише свої; JWT: видача, 401 + WWW-Authenticate без токена, неправильний пароль, refresh, підправлений / чужий / прострочений токен, токен, підписаний SECRET_KEY, приймається (тому ключ — секрет), throttle 429; хеш з сіллю, скидання пароля без розкриття акаунтів.
Практика¶
Розібраний приклад: правило автора в одному місці¶
Правило «змінює лише автор», записане в кількох місцях, легко забути в одному з них. У DRF для цього є permission-клас з методом has_object_permission:
- Безпечні методи (
GET,HEAD,OPTIONS—permissions.SAFE_METHODS) дозволено всім, хто бачить об'єкт: видимість уже вирішили selectors. - Решта — лише якщо
obj.user_id == request.user.id. - У ViewSet —
permission_classes = [IsAuthenticated, IsAuthorOrReadOnly]і викликself.check_object_permissions(request, note)після того, як нотатку знайдено: уViewSetбезget_object()DRF сам його не викликає.
from rest_framework import permissions
class IsAuthorOrReadOnly(permissions.BasePermission):
message = "Змінювати й видаляти нотатку може лише її автор."
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.user_id == request.user.id
Тоді _get_own_note не потрібен: _get_note + self.check_object_permissions(request, note) у кожній дії. Той самий клас можна повісити на майбутній API списків покупок — правило одне.
Зміни приклад¶
- Дозволь учасникам групи закріплювати нотатку групи (
pin), але не змінювати й не видаляти. - Зменш
ACCESS_TOKEN_LIFETIMEдо 1 хвилини й переконайся (тестом ізset_exp), що клієнт отримує401і має використати refresh.
Спробуй самостійно: «вийти на всіх пристроях»¶
Додай rest_framework_simplejwt.token_blacklist в INSTALLED_APPS, "BLACKLIST_AFTER_ROTATION": True у SIMPLE_JWT і ендпоінт POST /api/token/logout/ (TokenBlacklistView).
Критерії перевірки: після logout той самий refresh-токен на /api/token/refresh/ дає 401; migrate створює таблиці чорного списку; тест у tests_auth.py.
Знайди помилку¶
Проєкт уроку лежить у публічному репозиторії на GitHub, а на сервері його запустили як є — без DJANGO_SECRET_KEY. Хтось прочитав settings.py:
import jwt
from django.conf import settings
print("ключ з репозиторію:", settings.SECRET_KEY[:20] + "…")
forged = jwt.encode({"token_type": "access", "user_id": str(olena.id), "exp": 4102444800, "jti": "x"},
settings.SECRET_KEY, algorithm="HS256")
api = APIClient()
api.credentials(HTTP_AUTHORIZATION=f"Bearer {forged}")
response = api.get("/api/notes/")
print("підроблений токен →", response.status_code, [note["title"] for note in response.json()])
ключ з репозиторію: django-insecure-cris…
підроблений токен → 200 ['Пароль від Wi-Fi (новий)', 'Подарунок Тарасу']
Жодного пароля, жодного виклику /api/token/ — а доступ до всіх нотаток Олени до 2100 року. Що пішло не так і що робити, якщо це вже сталося?
Відповідь
SECRET_KEY — єдиний секрет, яким підписано JWT (а ще сесії й посилання скидання пароля). Хто його знає, той сам випускає «справжні» токени для будь-якого user_id з будь-яким exp: перевірка підпису на сервері пройде, бо підпис правильний. Тому ключ не можна тримати в коді, який бачать інші.
Що робити:
- Зараз — новий ключ (
secrets.token_urlsafe(50)) у змінну середовищаDJANGO_SECRET_KEYна сервері й перезапуск: усі токени, сесії й посилання скидання, підписані старим ключем, стають недійсними — користувачі входять знову. - Назавжди — ключ лише в оточенні сервера (урок 50);
check --deployпопереджає про ключ з префіксомdjango-insecure-; у репозиторій — лише значення для навчання. - Ключ, що потрапив у git, вважається скомпрометованим назавжди: видалення коміту не допомагає, історію вже могли скопіювати.
Підсумок¶
| Поняття | Що запам'ятати |
|---|---|
| Автентифікація / авторизація | «хто ти» (сесія, JWT) / «що тобі можна» (selectors, перевірка автора, permissions) |
| Нотатки групи | Q(user=…) \| Q(group__in=user.groups.all()); читати — учасникам, змінювати — автору |
404 / 403 |
не бачиш об'єкт — 404; бачиш, але дія заборонена — 403 |
| Хеш пароля | pbkdf2_sha256$ітерації$сіль$хеш; сіль — випадкова, хешування — навмисно повільне |
| Скидання пароля | однакова відповідь для будь-якого email; токен залежить від хешу пароля — одноразовий |
| JWT | header.payload.signature; payload читає будь-хто, підробити без SECRET_KEY не можна |
| Access / refresh | короткий для запитів / довгий для оновлення; 401 — оновити токен |
| Порядок автентифікаторів DRF | перший визначає 401 (+ WWW-Authenticate) чи 403 |
| Throttle | обмеження спроб входу: 429, Retry-After |
check --deploy |
список того, що змінити в налаштуваннях перед сервером |
| OWASP Top 10 | A01 доступ, A02 криптографія, A03 ін'єкції, A05 налаштування, A07 автентифікація |
Самоперевірка¶
- Чому після додавання груп не можна просто залишити
user=request.userу selectors? - Чому учасник групи на
DELETEотримує403, а сторонній —404? - Що буде в базі, якщо двоє користувачів мають однаковий пароль?
- Що можна прочитати з JWT без ключа і що без ключа зробити неможливо?
- Навіщо access-токен живе лише 5 хвилин, якщо є refresh?
- Чому неавтентифікований запит до API тепер отримує
401, а в уроці 36 отримував403? - Що робити, якщо
SECRET_KEYпотрапив у публічний репозиторій?
Відповіді
- Учасники групи мають бачити її нотатки; фільтр лише за автором їх сховає. Видимість —
Q(user) | Q(group__in=…), а зміни — окрема перевірка автора. - Учасник і так бачить нотатку — приховувати нічого, чесна відповідь «заборонено». Сторонньому
403підтвердив би, що нотатка з таким id існує. - Два різні рядки: сіль випадкова, тож хеші різні. З бази не видно, що паролі однакові.
- Прочитати — header і payload (це base64). Змінити їх і отримати правильний підпис без
SECRET_KEY— неможливо; сервер відхилить з401. - Викрадений access діє максимум 5 хвилин. Refresh використовується рідко й лише на одному ендпоінті, тож його важче перехопити; з ротацією він ще й змінюється щоразу.
- Першим тепер
JWTAuthentication, у нього єWWW-Authenticate: Bearer— DRF відповідає401. У уроці 36 першою була сесія без такого заголовка —403. - Негайно згенерувати новий ключ у змінну середовища й перезапустити сервер (усі токени й сесії стануть недійсними); ключ з git вважати скомпрометованим назавжди.
Що далі¶
- Ноутбук заняття: Відкрити вправи в Colab Переглянути розв’язки.
- Урок 42 — тестування API на агрегаторі новин: тестова база, підміна мережі, моки.
- Урок 47 — security advanced: SSRF, секрети, заголовки, JWT для адмін-ендпоінтів агрегатора.
Документація і джерела¶
- Код:
crispy_notes_project— проєкт уроку 36 + групи, скидання пароля й налаштування безпеки зі стартовогоcrispy_notes_project. - Django-книга, частина VII: огляд, автентифікація, сесії, права доступу, архітектура безпеки Django, типові помилки, OWASP Top 10.
- Django: Password management, Using the authentication system, Deployment checklist, Security in Django
- DRF: Authentication, Permissions, Throttling; Simple JWT
- RFC 7519 — JSON Web Token, jwt.io — розібрати токен
- OWASP Top 10, OWASP Password Storage Cheat Sheet