Автентифікація та авторизація — JWT, OAuth2, RBAC
Автентифікація та авторизація — JWT, OAuth2, RBAC
Чому безпека є першим пріоритетом
Кожен застосунок, що обробляє дані користувачів, рано чи пізно зіткнеться з одним і тим самим фундаментальним питанням: хто ти такий і що тобі дозволено робити? Саме ці два питання формують два наріжні камені безпеки будь-якої системи — автентифікацію (authentication) та авторизацію (authorization).
На перший погляд, їхні назви схожі. Але різниця принципова:
Автентифікація (Authentication)
Авторизація (Authorization)
Типова послідовність виглядає так: спочатку система автентифікує — перевіряє логін і пароль, видає токен. Потім — при кожному наступному запиті — авторизує — перевіряє токен і вирішує, чи має цей користувач доступ до конкретного ресурсу.
[Authorize] атрибут, middleware UseAuthentication() / UseAuthorization(), схеми JwtBearerDefaults.AuthenticationScheme, Identity framework та claims-based модель. У FastAPI підхід концептуально ідентичний, але реалізований через систему Dependency Injection (Depends()), Security Schemes та декоратори роутів. Цю статтю побудовано так, щоб виявити паралелі та відмінності між двома підходами.Частина 1. Хешування паролів — фундамент безпеки
Чому не можна зберігати паролі відкритим текстом
Уявіть таку ситуацію: ваша база даних була скомпрометована — зловмисник отримав дамп таблиці users. Якщо паролі зберігалися відкритим текстом (або навіть у форматі Base64), він отримав одразу всі паролі всіх користувачів. Враховуючи, що більшість людей використовують один пароль на декількох сайтах, збитки будуть катастрофічними.
Правильне рішення — зберігати не сам пароль, а його криптографічний хеш. При вході користувач знову вводить пароль, система знову хешує його і порівнює два хеші. Якщо вони збігаються — пароль правильний.
Але не всі хеш-функції однаково безпечні. Розглянемо еволюцію підходів:
Ключова ідея: хеш-функції для паролів навмисно повільні. Це не баг — це фіча. Якщо перевірка одного пароля займає 100 мілісекунд, то атака перебором 1 мільярду паролів займе ~115 днів навіть на одному ядрі. На відміну від SHA-256, де GPU може перевіряти мільярди варіантів за секунду.
Порівняльна таблиця: прості хеш-функції vs спеціалізовані
Щоб зробити відмінності максимально наочними, розглянемо конкретні числа. Усі вимірювання — на сучасному споживчому CPU (Intel/AMD 2023 р.) та GPU RTX 4090.
| Алгоритм | Призначення | Час одного хешу (CPU) | Хешів/сек (CPU) | Хешів/сек (RTX 4090) | Вбудований salt | Захист від GPU | Налаш- тована складність |
|---|---|---|---|---|---|---|---|
| MD5 | Контрольна сума файлів | ~1 нс | ~30 млрд | ~300 млрд | ❌ | ❌ | ❌ |
| SHA-1 | Цифровий підпис (застарілий) | ~2 нс | ~15 млрд | ~150 млрд | ❌ | ❌ | ❌ |
| SHA-256 | Цілісність даних, TLS | ~4 нс | ~500 млн | ~22 млрд | ❌ | ❌ | ❌ |
| SHA-512 | Цілісність, підписи | ~6 нс | ~300 млн | ~9 млрд | ❌ | ❌ | ❌ |
| bcrypt (cost=10) | Паролі | ~40 мс | ~25 | ~300 | ✅ | ⚠️ частково | ✅ |
| bcrypt (cost=12) | Паролі (рекомендовано) | ~160 мс | ~6 | ~80 | ✅ | ⚠️ частково | ✅ |
| scrypt (N=32768) | Паролі | ~100 мс | ~10 | ~15 | ✅ | ✅ сильний | ✅ |
| Argon2id (t=2, m=64MB) | Паролі (найкращий вибір) | ~120 мс | ~8 | ~12 | ✅ | ✅ дуже сильний | ✅ |
Щоб перетворити ці числа на реальне відчуття загрози, розглянемо сценарій атаки: зловмисник отримав ваш дамп БД і хоче зламати пароль P@ssw0rd! (9 символів, змішаний регістр + цифри + спецсимвол, ~3 квадрильйони комбінацій):
| Алгоритм | Швидкість GPU (RTX 4090) | Час повного перебору |
|---|---|---|
| MD5 | 300 млрд/сек | ~2.8 години |
| SHA-256 | 22 млрд/сек | ~38 годин |
| bcrypt cost=12 | 80/сек | ~1.2 мільярда років |
| Argon2id | 12/сек | ~7.9 мільярда років |
password123, qwerty, iloveyou), а не повний перебір. Тому надійний, довгий та унікальний пароль — така ж важлива умова безпеки, як і правильний алгоритм хешування.Salt: захист від таблиць передобчислень
Ще одна концепція, яку важливо розуміти — сіль (salt). Це випадковий рядок, який додається до пароля перед хешуванням. Кожен пароль отримує унікальний salt.
Навіщо це потрібно? Без salt два користувачі з однаковим паролем secret123 матимуть однаковий хеш у базі даних. Зловмисник це помітить і зрозуміє, що паролі однакові. Більше того, він може заздалегідь побудувати так звану rainbow table — таблицю відповідності «пароль → хеш» для мільйонів популярних паролів і миттєво знайти збіг.
Salt вирішує цю проблему: навіть якщо два користувачі мають однаковий пароль, їхні хеші будуть абсолютно різними, оскільки salt у кожного унікальний.
Хешування паролів у Python: перехід на чистий bcrypt
Історично стандартним вибором в екосистемі Python для роботи з паролями була бібліотека passlib (зокрема її клас CryptContext). Вона дозволяла зручно керувати багатьма алгоритмами.
Однак на сьогодні бібліотека passlib є фактично закинутою (unmaintained) і не оновлюється. Це призводить до серйозних проблем із сумісністю: при використанні нових версій бібліотеки bcrypt (від 4.0.0 і вище) виникає помилка:
AttributeError: module 'bcrypt' has no attribute '__about__'
Тому сучасним рекомендованим підходом є використання офіційної бібліотеки bcrypt напряму.
pip install bcrypt
uv add bcrypt
poetry add bcrypt
Обмеження bcrypt у 72 байти
Алгоритм bcrypt має вбудоване обмеження: максимальна довжина вхідних даних для хешування становить 72 байти. Якщо передати довший пароль, сучасна бібліотека bcrypt згенерує помилку ValueError.
Щоб підтримувати паролі будь-якої довжини і гарантувати безпеку, перед передачею пароля до bcrypt його кодують у UTF-8 і обрізають до перших 72 байтів: plain_password.encode("utf-8")[:72].
Реалізація у проєкті TaskForge
Ось як виглядає рекомендована реалізація модуля app/core/security.py з прямим використанням bcrypt:
# app/core/security.py
import bcrypt
def hash_password(plain_password: str) -> str:
"""
Хешує пароль за допомогою bcrypt з автоматично згенерованою сіллю.
Пароль попередньо обрізається до 72 байтів (ліміт алгоритму bcrypt).
Args:
plain_password: Пароль у відкритому вигляді.
Returns:
Рядок хешу, що містить версію, work factor (rounds), salt та сам хеш.
Приклад: "$2b$12$..."
"""
# Обрізаємо пароль до 72 байтів для запобігання ValueError
pwd_bytes = plain_password.encode("utf-8")[:72]
# Генеруємо сіль із рекомендованим work factor (rounds=12)
salt = bcrypt.gensalt(rounds=12)
# Хешуємо та декодуємо результат у рядок UTF-8
return bcrypt.hashpw(pwd_bytes, salt).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Перевіряє відповідність пароля збереженому хешу.
Args:
plain_password: Пароль у відкритому вигляді (від користувача).
hashed_password: Хеш із бази даних.
Returns:
True, якщо пароль відповідає хешу. False — інакше.
"""
pwd_bytes = plain_password.encode("utf-8")[:72]
hashed_bytes = hashed_password.encode("utf-8")
return bcrypt.checkpw(pwd_bytes, hashed_bytes)
rounds визначає обчислювальну складність операції хешування (кількість ітерацій дорівнює $2^{12} = 4096$). Значення 12 є оптимальним стандартом на сьогодні: воно займає близько 150-200 мс на сучасному процесорі, що ускладнює brute-force атаки на відеокартах, але є непомітним для користувача під час авторизації.Спадкова альтернатива: використання passlib та CryptContext
Якщо у вашому проєкті вже використовується passlib і швидка міграція на прямий bcrypt неможлива, ви можете налаштувати контекст через CryptContext.
AttributeError: module 'bcrypt' has no attribute '__about__' у вас є два варіанти швидкого вирішення:- Знизити версію bcrypt: встановити старішу версію бібліотеки (наприклад,
pip install bcrypt==3.2.2). Цей варіант є тимчасовим фіксом, але не рекомендується з міркувань безпеки. - Перейти на Argon2id: встановити
argon2-cffiі використовувати схему"argon2".
Для ознайомлення нижче наведено опис роботи з CryptContext:
Клас CryptContext
Центральним об'єктом passlib є CryptContext — контекст, що інкапсулює вибір алгоритму, параметри та логіку верифікації.
from passlib.context import CryptContext["bcrypt", "argon2"]."auto" автоматично позначає всі схеми, крім активної.Ключові методи CryptContext:
secret з використанням активного алгоритму та автоматично генерованим salt. Повертає повний рядок хешу, що містить всю необхідну інформацію.hashed = pwd_context.hash("mysecretpassword")
# Результат: "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW"
secret раніше збереженому хешу hash. Автоматично визначає алгоритм та параметри з самого рядка хешу.is_valid = pwd_context.verify("mysecretpassword", hashed_from_db)
# True або False
verify. Повертає кортеж (is_valid, new_hash). Якщо хеш застарів (використовує deprecated алгоритм або застарілі параметри), new_hash буде новим хешем — зберегти у БД. Якщо хеш актуальний — new_hash буде None.is_valid, new_hash = pwd_context.verify_and_update(password, hash_from_db)
if new_hash:
# зберегти new_hash у базі даних
await user_repo.update_password(user_id, new_hash)
PasswordHasher<TUser>, що реалізує інтерфейс IPasswordHasher<TUser>. За замовчуванням він використовує PBKDF2 з HMAC-SHA512, 100 000 ітерацій і 128-бітний salt. Метод HashPassword() аналогічний pwd_context.hash(), а VerifyHashedPassword() — pwd_context.verify().Ключова відмінність: ASP.NET Identity повертає PasswordVerificationResult.SuccessRehashNeeded якщо хеш застарів — аналог verify_and_update() у passlib.Поглиблено: як влаштований bcrypt зсередини
Щоб bcrypt не залишався для вас «чорною скринькою», варто зрозуміти, як він влаштований. Це допоможе приймати свідомі архітектурні рішення: який work factor обрати, чому bcrypt важко атакувати GPU, що саме зберігається у рядку хешу.
Походження: шифр Blowfish
bcrypt був запропонований у 1999 році Niels Provos та David Mazières. В основі алгоритму лежить симетричний блоковий шифр Blowfish, розроблений Брюсом Шнаєром у 1993 році. Але bcrypt не просто шифрує пароль через Blowfish — він використовує спеціально модифіковану, навмисно дорогу версію процедури розгортання ключів, що отримала назву Eksblowfish (Expensive Key Schedule Blowfish).
Зверніть увагу на крок ③: саме там знаходиться весь «секрет» повільності bcrypt. Цикл виконується 2^N разів, де N — ваш cost (work factor). При cost=12 це 4096 ітерацій; при cost=13 — вже 8192. Кожне збільшення cost на 1 подвоює час обчислення. Цей підхід ще називають key stretching.
Ліміт у 72 байти — підводний камінь
Одна з небагатьох справжніх слабкостей bcrypt, яку важливо знати: алгоритм обрізає пароль до 72 байтів. Якщо користувач має пароль довший за 72 символи ASCII (або менше символів Unicode, якщо вони багатобайтні), все, що виходить за межі, ігнорується.
На практиці це означає, що паролі correcthorsebatterystaple12345678901234567890123456789012345678901234 (73 символи) та correcthorsebatterystaple1234567890123456789012345678901234567890123 (72 символи) дадуть однаковий хеш.
Анатомія рядка хешу bcrypt
Результат хешування через bcrypt — це не просто набір байтів. Це самодостатній рядок, що кодує у собі всю інформацію, необхідну для верифікації. Зберігаючи цей рядок у базі даних, вам більше нічого не потрібно.
Розберемо приклад реального хешу:
$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
$2$ (оригінальний), $2a$ (виправлена специфікація 2011 р.), $2x$ / $2y$ (OpenBSD-специфічні виправлення) та $2b$ — рекомендована поточна версія, виправлена у 2014 р. Бібліотека passlib за замовчуванням генерує $2b$../ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789). Рівно 22 символи = 132 біти, з яких реально використовуються 128 (останні 4 біти ігноруються через особливості кодування).OrpheanBeholderScryDoubt. 31 символ кодує 184 біти з 192 (останні 8 біт відкидаються). Саме цей сегмент порівнюється при верифікації.Весь рядок разом має 60 символів. Це завжди фіксована довжина для bcrypt — незалежно від довжини вхідного пароля. Ця властивість сама по собі корисна: розмір стовпця у базі даних завжди відомий наперед (CHAR(60) або VARCHAR(60)).
Work factor: скільки обрати?
Вибір work factor — це баланс між безпекою та продуктивністю. Мета: щоб одне хешування займало ~100–250 мс на вашому production-сервері. Цього достатньо, щоб зробити перебір практично неможливим, але не занадто довго для легітимних користувачів.
| Work factor (cost) | Ітерацій (2^N) | Час на сучасному CPU | Хешів/сек (атака) |
|---|---|---|---|
| 10 | 1 024 | ~40 мс | ~25/с |
| 11 | 2 048 | ~80 мс | ~12/с |
| 12 | 4 096 | ~160 мс | ~6/с ← рекомендовано |
| 13 | 8 192 | ~320 мс | ~3/с |
| 14 | 16 384 | ~640 мс | ~1.5/с |
| 16 | 65 536 | ~2.5 с | ~0.4/с |
passlib робить це автоматично через verify_and_update().Чому bcrypt важко атакувати GPU
Сучасні GPU мають тисячі паралельних ядер і ідеально підходять для атак на SHA-256: можна перевіряти мільярди паролів за секунду. Bcrypt спеціально спроектований, щоб цьому протистояти.
Головна причина — великий розмір стану. Алгоритм Eksblowfish оперує таблицями розміром ~4 КБ (P-масив + S-boxes). Кожне ядро GPU має дуже мало швидкої пам'яті (регістрів) — набагато менше, ніж потрібно для паралельного зберігання кількох станів bcrypt. Це змушує GPU або обчислювати bcrypt послідовно (не використовуючи паралелізм), або постійно читати з повільної відеопам'яті.
Наслідок: bcrypt на GPU дає прискорення лише в 10–50 разів порівняно з CPU, а не в тисячі разів, як для SHA-256. Для практичних атак це фактично зводить nance GPU до нуля.
Бібліотека argon2-cffi — переможець PHC
Якщо bcrypt є перевіреним вибором, то Argon2 — переможцем Password Hashing Competition 2015 і рекомендованим стандартом OWASP для нових систем. Він має три варіанти: argon2d (стійкий до GPU), argon2i (стійкий до side-channel атак) та argon2id (гібридний, рекомендований).
pip install argon2-cffi
uv add argon2-cffi
poetry add argon2-cffi
# Альтернативна реалізація через argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# PasswordHasher з параметрами за замовчуванням OWASP
ph = PasswordHasher(
time_cost=2, # кількість ітерацій
memory_cost=65536, # 64 MB пам'яті (захист від GPU)
parallelism=2, # паралельні потоки
hash_len=32, # довжина хешу в байтах
salt_len=16, # довжина salt
)
hashed = ph.hash("mysecretpassword")
try:
ph.verify(hashed, "mysecretpassword") # True або виняток
# Перевірка чи потрібне перехешування
if ph.check_needs_rehash(hashed):
new_hash = ph.hash("mysecretpassword")
# Зберегти new_hash у БД
except VerifyMismatchError:
# Неправильний пароль
pass
bcrypt або argon2-cffi є найкращим вибором. argon2-cffi (алгоритм Argon2id) є сучасним індустріальним стандартом, оскільки забезпечує максимальну стійкість і не має 72-байтового ліміту довжини пароля, властивого bcrypt.Частина 2. JWT — JSON Web Token
Проблема, яку вирішує JWT
Після того як користувач успішно ввів логін і пароль, виникає нова проблема: як сервер ідентифікуватиме його при наступних запитах? HTTP — протокол без стану (stateless). Кожен запит незалежний від попередніх. Сервер не «пам'ятає», що саме цей клієнт вже автентифікувався.
Існує два класичні підходи:
Session-based (сесії)
Сервер зберігає сесію у пам'яті або БД. Клієнт отримує session_id у cookie. При кожному запиті сервер шукає сесію за session_id.
Мінус: стан зберігається на сервері → складно масштабувати горизонтально (кілька серверів мають бачити одну сесію → потрібен Redis або sticky sessions).
Token-based (токени)
Сервер видає підписаний токен із вбудованими даними про користувача. Клієнт надсилає токен у кожному запиті. Сервер перевіряє підпис і читає дані без звернення до БД.
Перевага: повністю stateless — будь-який сервер у кластері може перевірити токен без спільного стану.
JWT (JSON Web Token) — найпоширеніший стандарт для token-based автентифікації. Він описаний у RFC 7519 і є відкритим форматом для безпечної передачі інформації між сторонами у вигляді JSON-об'єкта. Ключова властивість: інформація у токені верифікована та надійна, оскільки він цифрово підписаний.
Анатомія JWT-токена
JWT складається рівно з трьох частин, розділених крапкою:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Візуально:
HEADER PAYLOAD SIGNATURE
eyJhbGci... . eyJzdWIiOiIx... . SflKxwRJSM...
+ замінені на -, / на _, і знаки = (padding) опускаються. Тому JWT можна безпечно передавати у HTTP-заголовках, query параметрах і cookie без URL-кодування.Claims — твердження у Payload
Частина Payload містить набір пар ключ-значення, які називаються claims (твердження). JWT-стандарт визначає три категорії:
| Claim | Повна назва | Значення |
|---|---|---|
iss | Issuer | Хто видав токен (назва сервісу) |
sub | Subject | Про кого токен (ID користувача) |
aud | Audience | Для кого токен (назва клієнта) |
exp | Expiration Time | Unix timestamp — коли токен спливає |
nbf | Not Before | Unix timestamp — з якого моменту токен дійсний |
iat | Issued At | Unix timestamp — коли токен видано |
jti | JWT ID | Унікальний ідентифікатор токена (для blacklist) |
email, name, picture.role, project_id, permissions. Використовуйте обережно — не кладіть чутливі дані (пароль, платіжні дані), бо Payload лише закодований Base64URL, але не зашифрований.Access Token + Refresh Token — правильний flow
Використовувати лише один токен з довгим терміном дії — небезпечно: якщо його вкрадуть, зловмисник матиме доступ до системи тривалий час. Галузевий стандарт — двотокенна система:
Чому два токени, а не один?
Authorization: Bearer <token>. Сервер перевіряє лише підпис — без звернення до БД. Саме через це він повинен бути короткоживучим: якщо токен вкрадуть, він швидко протухне./auth/refresh. Принцип ротації: при кожному використанні старий refresh token видаляється і видається новий — це дозволяє виявляти крадіжку токена (якщо хтось використав токен, що вже був використаний — це сигнал компрометації).Refresh Token: який формат обрати?
Ось де криється важливий архітектурний нюанс, який часто залишається «за кадром» у більшості туторіалів. Те, що ми назвали «Refresh Token», не зобов'язане бути JWT. Насправді існує два принципово різних підходи, і вибір між ними має серйозні наслідки для безпеки та архітектури.
Підхід 1: Refresh Token як JWT
Refresh token у форматі JWT — це той самий підписаний JSON Web Token, але з довшим exp і claim "type": "refresh". Він самодостатній: сервер може верифікувати його підпис без звернення до бази даних.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyLTEyMyIsInR5cGUiOiJyZWZyZXNoIiwiZXhwIjoxNzIwMjU5MjAwfQ
.signature
Переваги:
- Не потребує запису до БД при видачі — менше навантаження
- Stateless верифікація: будь-який сервер у кластері перевіряє самостійно
Критична проблема:
Якщо JWT refresh token вкрадений, його неможливо відкликати достроково. JWT дійсний до закінчення exp — і крапка. Єдиний спосіб інвалідувати його — ведення blacklist (список відкликаних токенів) у Redis або БД. Але тоді втрачається вся «stateless» перевага, і ви отримуєте найгіршу комбінацію: складність JWT плюс необхідність зберігати стан.
exp). Ні logout, ні зміна пароля не допоможуть — JWT вже виданий і підписаний.Підхід 2: Refresh Token як непрозорий випадковий рядок (рекомендовано)
Refresh token — це просто криптографічно безпечний випадковий рядок (opaque token), що не несе жодних вбудованих даних. Уся інформація про нього зберігається виключно в базі даних.
import secrets
# Генерація: 32 байти = 64 символи hex = 256 біт ентропії
refresh_token = secrets.token_urlsafe(32)
# Наприклад: "bX3R9qKm7Vn2pL8sA1jD5cW4uY6oE0tF"
У базі даних зберігається не сам токен, а його bcrypt-хеш (або SHA-256, якщо час хешування критичний), разом із метаданими:
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash TEXT NOT NULL UNIQUE, -- bcrypt або sha256 від токена
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ, -- для м'якого відкликання
user_agent TEXT, -- для аудиту: який браузер/пристрій
ip_address INET -- для аудиту
);
Переваги:
- Миттєве відкликання: достатньо DELETE або UPDATE запису — токен стає недійсним моментально
- Аудит сесій: можна показати користувачу список активних сесій (пристроїв) і дозволити завершити будь-яку
- Виявлення крадіжки через ротацію: якщо хтось використав вже використаний токен — це сигнал компрометації, можна відкликати всі сесії цього користувача
Порівняльна таблиця: JWT vs Opaque refresh token
| Критерій | JWT Refresh Token | Opaque (випадковий) |
|---|---|---|
| Формат | header.payload.signature | Випадковий рядок (base64url) |
| Вбудовані дані | Так (sub, exp, claims) | Ні — лише lookup key |
| Зберігається у БД | Не обов'язково (stateless) | Так, завжди |
| Верифікація | Перевірка підпису (без БД) | Пошук у БД за хешем |
| Миттєве відкликання | ❌ Ні (потрібен blacklist) | ✅ Так (DELETE рядка) |
| Аудит сесій | ❌ Важко | ✅ Легко (окремий рядок на сесію) |
| Виявлення крадіжки | ❌ Без blacklist — неможливо | ✅ Через ротацію |
| Навантаження на БД | Менше | 1 запит при кожному refresh |
| Складність реалізації | Проста | Середня |
| Рекомендація OWASP | ⚠️ З blacklist | ✅ Preferred |
secrets.token_urlsafe(32)), збережений у таблиці refresh_tokens у вигляді SHA-256 хешу (достатньо для lookup, bcrypt зайво повільний для цього випадку). Access token залишається JWT — він короткоживучий і stateless верифікація тут виправдана. Це поєднує найкращі властивості обох підходів.Бібліотека python-jose — створення та верифікація JWT
python-jose — найпопулярніша Python-бібліотека для роботи з JWT та JWS (JSON Web Signature). Підтримує широкий набір алгоритмів підпису: симетричні (HS256, HS512) та асиметричні (RS256, ES256).
pip install "python-jose[cryptography]"
# [cryptography] — бекенд для підтримки RSA та EC алгоритмів
uv add "python-jose[cryptography]"
poetry add "python-jose[cryptography]"
Модуль jose.jwt
Центральний інтерфейс бібліотеки — модуль jose.jwt з двома ключовими функціями:
claims(dict) — payload токена. Обов'язково включайтеexp(час спливання).key(str | bytes | dict) — секретний ключ для HMAC або приватний ключ RSA/EC.algorithm(str) — алгоритм підпису. За замовчуванням"HS256".
"header.payload.signature".from jose import jwt
from datetime import datetime, timedelta, timezone
token = jwt.encode(
claims={
"sub": "user-uuid-abc123",
"email": "user@example.com",
"exp": datetime.now(timezone.utc) + timedelta(minutes=15),
"iat": datetime.now(timezone.utc),
},
key="your-secret-key",
algorithm="HS256",
)
token(str) — рядок токена.key(str | bytes | dict) — секретний ключ (той самий, що при encode).algorithms(list[str]) — список дозволених алгоритмів. Завжди передавайте явно — це захист від атаки «algorithm confusion».options(dict, опційно) — додаткові опції верифікації.audience(str, опційно) — очікуване значенняaudclaim.
dict з claims токена.Викидає винятки:jose.ExpiredSignatureError— токен протух (expу минулому).jose.JWTError— базовий виняток: невалідний підпис, некоректний формат.jose.JWTClaimsError— невалідні claims (наприклад,audне збігається).
from jose import jwt, JWTError, ExpiredSignatureError
try:
payload = jwt.decode(
token=token_string,
key="your-secret-key",
algorithms=["HS256"], # завжди явний список!
)
user_id = payload["sub"]
except ExpiredSignatureError:
# токен протух — повернути 401 з підказкою оновити
raise HTTPException(status_code=401, detail="Token expired")
except JWTError:
# невалідний підпис або формат
raise HTTPException(status_code=401, detail="Invalid token")
Повна реалізація у TaskForge
Ось як виглядає модуль app/core/security.py з усіма функціями для роботи з токенами:
# app/core/security.py
import bcrypt
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import jwt, JWTError, ExpiredSignatureError
from app.core.config import settings # pydantic Settings
# ─── Хешування паролів ────────────────────────────────────────────────────────
def hash_password(plain_password: str) -> str:
pwd_bytes = plain_password.encode("utf-8")[:72]
salt = bcrypt.gensalt()
return bcrypt.hashpw(pwd_bytes, salt).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
pwd_bytes = plain_password.encode("utf-8")[:72]
return bcrypt.checkpw(pwd_bytes, hashed_password.encode("utf-8"))
# ─── Access JWT-токен ─────────────────────────────────────────────────────────
def create_access_token(
subject: str | Any,
extra_claims: dict | None = None,
) -> str:
"""
Створює короткоживучий Access JWT-токен.
Args:
subject: Ідентифікатор суб'єкта (зазвичай user UUID або ID).
Буде збережений у claim 'sub'.
extra_claims: Додаткові claims для вбудовування у payload
(наприклад, {'email': '...', 'role': 'admin'}).
Returns:
Підписаний JWT-рядок у форматі header.payload.signature.
"""
now = datetime.now(timezone.utc)
expire = now + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": str(subject),
"iat": now,
"exp": expire,
"type": "access", # захист від підміни: access token ≠ refresh token
}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_access_token(token: str) -> dict:
"""
Верифікує підпис Access JWT та повертає розкодований payload.
Args:
token: JWT-рядок.
Returns:
Словник з claims токена.
Raises:
ExpiredSignatureError: Токен протух (exp у минулому).
JWTError: Невалідний підпис або формат токена.
"""
return jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
# ─── Opaque Refresh Token ─────────────────────────────────────────────────────
def generate_refresh_token() -> str:
"""
Генерує криптографічно безпечний opaque refresh token.
Refresh token — це просто випадковий рядок з 256 бітами ентропії.
Він НЕ є JWT і не містить жодних вбудованих даних.
Вся інформація про токен зберігається в таблиці refresh_tokens у БД.
Returns:
URL-safe рядок з 43 символів (32 байти → base64url без padding).
Наприклад: "bX3R9qKm7Vn2pL8sA1jD5cW4uY6oE0tF_kzQwPn1pI"
"""
return secrets.token_urlsafe(32)
def hash_refresh_token(token: str) -> str:
"""
Повертає SHA-256 хеш refresh token для зберігання у БД.
Чому SHA-256, а не bcrypt?
- Refresh token вже має 256 біт ентропії (випадковий) —
rainbow table атаки принципово неможливі без знання токена.
- bcrypt навмисно повільний (~160 мс) — це зайво для lookup-операцій
у БД при кожному запиті refresh.
- SHA-256 тут достатньо: він захищає від витоку хешів з БД,
оскільки зворотно обчислити випадковий 256-бітний рядок — нереально.
Args:
token: Оригінальний refresh token у відкритому вигляді.
Returns:
HEX-рядок SHA-256 хешу (64 символи).
"""
return hashlib.sha256(token.encode()).hexdigest()
def verify_refresh_token(plain_token: str, stored_hash: str) -> bool:
"""
Перевіряє відповідність refresh token збереженому SHA-256 хешу.
Використовує secrets.compare_digest для захисту від timing attacks
(атака за часом виконання порівняння рядків).
Args:
plain_token: Токен у відкритому вигляді (від клієнта).
stored_hash: SHA-256 хеш з бази даних.
Returns:
True якщо токен відповідає хешу, False — інакше.
"""
expected_hash = hash_refresh_token(plain_token)
# secrets.compare_digest — постійний час порівняння, незалежно від відповіді
return secrets.compare_digest(expected_hash, stored_hash)
А ось оновлений app/core/config.py:
# app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# JWT (лише для Access Token)
SECRET_KEY: str # мінімум 32 байти, генерується: openssl rand -hex 32
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
# Opaque Refresh Token (зберігається у БД)
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
class Config:
env_file = ".env"
settings = Settings()
Зверніть увагу на розділення: SECRET_KEY та ALGORITHM використовуються виключно для Access JWT. Refresh token не потребує жодного секретного ключа — він просто зберігається у БД. Це чіткіша архітектура: якщо ви захочете перейти з HS256 на RS256, це ніяк не вплине на логіку refresh токенів.
JwtSecurityTokenHandler та клас SecurityTokenDescriptor. Конфігурація відбувається у Program.cs через AddAuthentication().AddJwtBearer(...) з параметрами TokenValidationParameters. У FastAPI аналогічна конфігурація — це SECRET_KEY, ALGORITHM у Settings та явний виклик jwt.encode() / jwt.decode(). ASP.NET Identity також підтримує opaque refresh token через IUserTokenProvider — концептуально це той самий підхід: випадковий рядок, збережений у таблиці AspNetUserTokens.Частина 3. OAuth2 у FastAPI
Що таке OAuth2 у контексті FastAPI
Коли розробники чують «OAuth2», вони часто думають про «Увійти через Google» або «Увійти через GitHub». Це OAuth2 Authorization Code Flow — стандарт для делегування доступу між сторонніми сервісами.
Але FastAPI використовує OAuth2 у вужчому сенсі: лише схему передачі токена у HTTP-запитах. Конкретно — OAuth2 Password Flow (він же grant_type=password), де клієнт відправляє логін і пароль безпосередньо на ваш сервер і отримує токен. Це доречно для власних клієнтів (мобайл-застосунок, SPA), де немає потреби у редиректах до стороннього провайдера.
authlib.OAuth2PasswordBearer — схема безпеки
OAuth2PasswordBearer — це FastAPI-об'єкт, що описує схему автентифікації для Swagger UI та витягує Bearer Token з заголовка Authorization.
Depends() — витягує JWT з заголовка Authorization: Bearer <token>. Якщо заголовок відсутній — автоматично повертає 401 Unauthorized.Імпорт:from fastapi.security import OAuth2PasswordBeareroauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
True (за замовчуванням) — автоматично повертає 401 при відсутності токена. Якщо False — повертає None, дозволяючи обробити відсутність токена вручну (для опційної автентифікації).Коли OAuth2PasswordBearer налаштований, Swagger UI автоматично отримує кнопку «Authorize» у правому верхньому куті. Користувач вводить логін і пароль, Swagger відправляє їх на tokenUrl, отримує токен і додає його до всіх наступних запитів як Authorization: Bearer <token>.
OAuth2PasswordRequestForm — форма логіну
OAuth2 Password Flow вимагає, щоб логін і пароль передавались не як JSON, а як application/x-www-form-urlencoded (HTML-форма). FastAPI надає готову dependency для цього.
application/x-www-form-urlencoded тіло запиту та повертає об'єкт з полями форми OAuth2.Імпорт:from fastapi.security import OAuth2PasswordRequestFormusername. За OAuth2 специфікацією ім'я поля — саме username, навіть якщо ваша система використовує email. У реалізації ви можете шукати користувача за email, прийнявши username як email.password — пароль у відкритому вигляді."password". FastAPI валідує це автоматично.["read:projects", "write:tasks"]. Опційно, за замовчуванням — порожній список.Ланцюжок авторизації: Depends(get_current_user)
Центральна концепція авторизації у FastAPI — ланцюжок залежностей. Замість атрибута [Authorize] на контролері (як в ASP.NET), у FastAPI авторизація реалізується через Depends(). Це робить процес явним, тестованим і гнучким.
Ось реалізація цього ланцюжка:
# app/api/deps.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, ExpiredSignatureError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_access_token
from app.db.session import get_db
from app.models.user import User
from app.repositories.user import UserRepository
# Один екземпляр на рівні модуля — токен береться з цього URL у Swagger
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
"""
Витягує та верифікує поточного користувача з JWT access token.
Dependency-функція першого рівня: перевіряє підпис токена
та завантажує User з бази даних.
Args:
token: JWT-рядок, автоматично витягнутий OAuth2PasswordBearer
з заголовка Authorization: Bearer <token>.
db: Сесія бази даних (async SQLAlchemy).
Returns:
Об'єкт User з бази даних.
Raises:
401 Unauthorized: Токен невалідний, протух або користувача не знайдено.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"}, # стандарт OAuth2
)
try:
payload = decode_access_token(token)
# Захист від підміни типу токена
if payload.get("type") != "access":
raise credentials_exception
user_id: str | None = payload.get("sub")
if user_id is None:
raise credentials_exception
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise credentials_exception
user_repo = UserRepository(db)
user = await user_repo.get_by_id(user_id)
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Перевіряє, що поточний користувач є активним (не заблокованим).
Dependency-функція другого рівня: надбудова над get_current_user.
Використовуйте цю залежність для більшості захищених ендпоінтів.
Args:
current_user: Об'єкт User від залежності get_current_user.
Returns:
Той самий User, якщо він активний.
Raises:
403 Forbidden: Акаунт деактивований.
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user account",
)
return current_user
Тепер захистити будь-який роут — одна строчка:
# app/api/v1/projects.py
from fastapi import APIRouter, Depends
from app.api.deps import get_current_active_user
from app.models.user import User
router = APIRouter()
@router.get("/projects")
async def list_projects(
current_user: User = Depends(get_current_active_user),
# ...
):
"""Повертає список проєктів поточного користувача."""
...
@router.post("/projects")
async def create_project(
current_user: User = Depends(get_current_active_user),
# ...
):
...
[Authorize] на контролері або методі. Middleware UseAuthentication() автоматично перевіряє JWT у кожному запиті та заповнює HttpContext.User. У FastAPI немає глобального middleware для автентифікації — кожен захищений роут явно декларує Depends(get_current_active_user). Це більше коду, але набагато більша гнучкість: різні роути можуть використовувати різні схеми автентифікації одночасно (JWT, API Key, OAuth2).Ендпоінти реєстрації та логіну
Ось повна реалізація app/api/v1/auth.py з ендпоінтами реєстрації, логіну, оновлення токена та логауту:
# app/api/v1/auth.py
import secrets
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import (
hash_password,
verify_password,
create_access_token,
generate_refresh_token,
hash_refresh_token,
verify_refresh_token,
)
from app.core.config import settings
from app.db.session import get_db
from app.models.user import User
from app.models.refresh_token import RefreshToken
from app.repositories.user import UserRepository
from app.repositories.refresh_token import RefreshTokenRepository
from app.schemas.auth import (
UserRegisterRequest,
TokenResponse,
RefreshRequest,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(
data: UserRegisterRequest,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
"""
Реєстрація нового користувача.
Хешує пароль, створює User у БД, одразу видає токени
(без окремого кроку логіну після реєстрації).
"""
user_repo = UserRepository(db)
token_repo = RefreshTokenRepository(db)
# Перевірка унікальності email
if await user_repo.get_by_email(data.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with this email already exists",
)
# Створення користувача
user = User(
email=data.email,
display_name=data.display_name,
hashed_password=hash_password(data.password),
)
await user_repo.create(user)
# Видача токенів
access_token = create_access_token(subject=str(user.id))
plain_refresh_token = generate_refresh_token()
await token_repo.create(RefreshToken(
token_hash=hash_refresh_token(plain_refresh_token),
user_id=user.id,
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
))
return TokenResponse(
access_token=access_token,
refresh_token=plain_refresh_token,
token_type="bearer",
)
@router.post("/login", response_model=TokenResponse)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
"""
Логін через OAuth2 Password Flow.
Приймає form-data (application/x-www-form-urlencoded),
а не JSON — це вимога OAuth2 специфікації.
Поле 'username' тут використовується як email.
"""
user_repo = UserRepository(db)
token_repo = RefreshTokenRepository(db)
# Знаходимо користувача (form_data.username — це email)
user = await user_repo.get_by_email(form_data.username)
# Перевіряємо credentials. Завжди виконуємо verify_password,
# навіть якщо user == None, щоб уникнути timing attack
# (різний час відповіді міг би видати факт існування акаунта)
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is deactivated",
)
# Видача токенів
access_token = create_access_token(subject=str(user.id))
plain_refresh_token = generate_refresh_token()
await token_repo.create(RefreshToken(
token_hash=hash_refresh_token(plain_refresh_token),
user_id=user.id,
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
))
return TokenResponse(
access_token=access_token,
refresh_token=plain_refresh_token,
token_type="bearer",
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_tokens(
body: RefreshRequest,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
"""
Оновлення пари токенів через opaque refresh token.
Реалізує ротацію: старий refresh token знищується,
видається новий. Якщо token вже використаний (не знайдений у БД) —
це сигнал крадіжки: відкликаємо ВСІ сесії користувача.
"""
token_repo = RefreshTokenRepository(db)
# Шукаємо токен у БД за SHA-256 хешем
stored_token = await token_repo.get_by_hash(hash_refresh_token(body.refresh_token))
if not stored_token or stored_token.expires_at < datetime.now(timezone.utc):
# Токен не знайдений або протух
# Якщо не знайдений — можлива ротаційна атака.
# Для простоти тут повертаємо 401; у production — додаткова логіка.
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
)
user_id = stored_token.user_id
# Ротація: видаляємо старий токен
await token_repo.delete(stored_token.id)
# Видаємо нові токени
access_token = create_access_token(subject=str(user_id))
plain_refresh_token = generate_refresh_token()
await token_repo.create(RefreshToken(
token_hash=hash_refresh_token(plain_refresh_token),
user_id=user_id,
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
))
return TokenResponse(
access_token=access_token,
refresh_token=plain_refresh_token,
token_type="bearer",
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
body: RefreshRequest,
db: AsyncSession = Depends(get_db),
) -> None:
"""
Логаут: відкликає refresh token.
Access token залишається дійсним до закінчення exp (15 хв).
Це прийнятний trade-off для stateless JWT.
"""
token_repo = RefreshTokenRepository(db)
stored_token = await token_repo.get_by_hash(hash_refresh_token(body.refresh_token))
if stored_token:
await token_repo.delete(stored_token.id)
/login. Якщо б ми повертали різні помилки для «користувача не знайдено» та «неправильний пароль», зловмисник міг би перебором визначати, які email зареєстровані у системі. Завжди повертайте однакову загальну помилку: "Incorrect email or password".Повна реалізація: копіюй та запускай
Усі попередні приклади були розбиті на шматки для пояснення концепцій. Тут — повна, робоча реалізація всього auth-шару TaskForge. Жодних ..., жодних посилань на невизначені модулі.
Структура файлів
taskforge/
├── .env
├── create_tables.py
└── app/
├── main.py
├── core/
│ ├── config.py
│ └── security.py
├── db/
│ └── session.py
├── models/
│ ├── base.py
│ ├── user.py
│ └── refresh_token.py
├── schemas/
│ └── auth.py
├── repositories/
│ ├── user.py
│ └── refresh_token.py
└── api/
├── deps.py
└── v1/
└── auth.py
Встановлення залежностей
pip install fastapi "uvicorn[standard]" "sqlalchemy[asyncio]" asyncpg \
bcrypt "python-jose[cryptography]" pydantic-settings \
email-validator
uv add fastapi "uvicorn[standard]" "sqlalchemy[asyncio]" asyncpg \
bcrypt "python-jose[cryptography]" pydantic-settings \
email-validator
poetry add fastapi "uvicorn[standard]" "sqlalchemy[asyncio]" asyncpg \
bcrypt "python-jose[cryptography]" pydantic-settings \
email-validator
Усі файли
Запуск
Запустити PostgreSQL
docker run --rm -d \
--name taskforge-db \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=taskforge \
-p 5432:5432 \
postgres:16
Створити таблиці
python create_tables.py
# Tables created successfully!
Запустити сервер
uvicorn app.main:app --reload --port 8000
Перевірити через Swagger
Відкрийте http://localhost:8000/docs.
Ви побачите кнопку «Authorize» та всі 4 ендпоінти: /register, /login, /refresh, /logout.
Частина 4. RBAC — Контроль доступу на основі ролей
Що таке RBAC і навіщо він потрібен
Ми вже вміємо відповідати на питання «хто ти?» (автентифікація). Тепер час відповісти на питання «що тобі дозволено?» (авторизація).
Найпоширеніший підхід — RBAC (Role-Based Access Control): кожному користувачу присвоюється роль, і кожна роль дає певний набір дозволів. Замість того, щоб призначати права кожному окремо, ми керуємо ролями — це масштабується навіть на мільйони користувачів.
RBAC — Role-Based
Хто ти за роллю? Права визначаються належністю до групи.
user → власні проєкти та задачіmanager → проєкти команди + звітиadmin → повний доступ
Просто, масштабується, легко реалізується.
ABAC — Attribute-Based
Які атрибути у суб'єкта, ресурсу та середовища?
дозволено: user.dept == project.dept AND hour < 18
Максимально гнучкий, але значно складніший. Підходить для enterprise з нетривіальними правилами.
Enum ролей
Перший крок — визначити ролі як Python Enum. Це строга типізація замість «магічних рядків» "admin", "user", які легко написати з помилкою.
# app/models/role.py
from enum import Enum
class UserRole(str, Enum):
"""
Ролі користувачів TaskForge.
Успадкування від str критично важливе:
- SQLAlchemy зберігає рядок ("user", "admin"), а не "UserRole.ADMIN"
- Pydantic коректно серіалізує у JSON без додаткових налаштувань
- Порівняння UserRole.ADMIN == "admin" -> True (завдяки str-наслідуванню)
"""
USER = "user" # звичайний користувач: власні проєкти та задачі
MANAGER = "manager" # менеджер: управління командою, перегляд звітів
ADMIN = "admin" # адміністратор: повний доступ, управління акаунтами
class UserRole(str, Enum) — критична деталь. Без наслідування від str SQLAlchemy зберігав би UserRole.ADMIN як "UserRole.ADMIN", а не "admin". З str-наслідуванням значення enum — це звичайний рядок, сумісний з колонкою VARCHAR у БД і JSON-серіалізацією Pydantic.Оновлення моделі User
Додаємо поле role до SQLAlchemy-моделі:
# app/models/user.py
import uuid
from sqlalchemy import Boolean, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.models.role import UserRole
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
hashed_password: Mapped[str] = mapped_column(String(60), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Роль зберігається як VARCHAR — str-значення enum ("user", "manager", "admin")
role: Mapped[UserRole] = mapped_column(
String(20),
default=UserRole.USER,
nullable=False,
)
refresh_tokens: Mapped[list["RefreshToken"]] = relationship( # type: ignore[name-defined]
"RefreshToken", back_populates="user", cascade="all, delete-orphan"
)
Dependency-фабрика require_roles()
Ключова ідея — Dependency Factory: функція, що приймає список дозволених ролей і повертає готову async-dependency із замиканням над цим списком. FastAPI вбудовує її у граф залежностей автоматично.
# app/api/deps.py — повна версія з підтримкою ролей
from collections.abc import Callable
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import ExpiredSignatureError, JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_access_token
from app.db.session import get_db
from app.models.role import UserRole
from app.models.user import User
from app.repositories.user import UserRepository
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
"""Dependency (рівень 1): перевіряє JWT та завантажує User з БД."""
unauthorized = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(token)
if payload.get("type") != "access":
raise unauthorized
user_id: str | None = payload.get("sub")
if user_id is None:
raise unauthorized
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise unauthorized
user = await UserRepository(db).get_by_id(user_id)
if user is None:
raise unauthorized
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""Dependency (рівень 2): перевіряє is_active."""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user account",
)
return current_user
def require_roles(*roles: UserRole) -> Callable:
"""
Dependency-фабрика для перевірки ролей.
Приймає одну або кілька дозволених ролей та повертає
dependency-функцію, яку FastAPI вбудовує у граф залежностей.
Args:
*roles: Перелік дозволених UserRole значень.
Returns:
Async dependency-функція -> User або 403 Forbidden.
Використання:
# Лише адміністратор:
Depends(require_roles(UserRole.ADMIN))
# Адміністратор або менеджер:
Depends(require_roles(UserRole.ADMIN, UserRole.MANAGER))
"""
allowed = set(roles)
async def role_checker(
current_user: User = Depends(get_current_active_user),
) -> User:
if current_user.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Required roles: {[r.value for r in allowed]}",
)
return current_user
return role_checker
Ендпоінти адмін-панелі
# app/api/v1/admin.py
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_roles
from app.db.session import get_db
from app.models.role import UserRole
from app.models.user import User
router = APIRouter(prefix="/admin", tags=["Administration"])
# Alias для зручності — щоб не дублювати require_roles(UserRole.ADMIN) скрізь
AdminOnly = Depends(require_roles(UserRole.ADMIN))
# ─── Pydantic схеми ───────────────────────────────────────────────────────────
class UserListResponse(BaseModel):
id: uuid.UUID
email: EmailStr
display_name: str
role: UserRole
is_active: bool
model_config = {"from_attributes": True}
class UserRoleUpdateRequest(BaseModel):
role: UserRole
# ─── Ендпоінти ────────────────────────────────────────────────────────────────
@router.get("/users", response_model=list[UserListResponse])
async def list_all_users(
current_user: User = AdminOnly,
db: AsyncSession = Depends(get_db),
) -> list[User]:
"""Повертає список усіх користувачів. Тільки для адміністраторів."""
result = await db.execute(select(User).order_by(User.email))
return list(result.scalars().all())
@router.patch("/users/{user_id}/role", response_model=UserListResponse)
async def update_user_role(
user_id: uuid.UUID,
body: UserRoleUpdateRequest,
current_user: User = AdminOnly,
db: AsyncSession = Depends(get_db),
) -> User:
"""Змінює роль. Адмін не може змінити власну роль."""
target = await db.get(User, user_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if target.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot change your own role",
)
target.role = body.role
await db.commit()
await db.refresh(target)
return target
@router.patch("/users/{user_id}/deactivate", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_user(
user_id: uuid.UUID,
current_user: User = AdminOnly,
db: AsyncSession = Depends(get_db),
) -> None:
"""Деактивує акаунт. Адмін не може деактивувати себе."""
target = await db.get(User, user_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if target.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot deactivate your own account",
)
target.is_active = False
await db.commit()
@router.get("/reports/summary")
async def reports_summary(
# Менеджери та адміністратори мають доступ до звітів
current_user: User = Depends(require_roles(UserRole.ADMIN, UserRole.MANAGER)),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Зведений звіт. Доступний менеджерам та адміністраторам."""
result = await db.execute(select(User))
users = result.scalars().all()
return {
"total_users": len(users),
"active_users": sum(1 for u in users if u.is_active),
"admins": sum(1 for u in users if u.role == UserRole.ADMIN),
"managers": sum(1 for u in users if u.role == UserRole.MANAGER),
"requested_by": current_user.email,
}
Реєструємо роутер у main.py:
# app/main.py
from fastapi import FastAPI
from app.api.v1 import admin, auth
app = FastAPI(
title="TaskForge API",
description="Project management API — JWT + Opaque Refresh Token + RBAC",
version="0.1.0",
)
app.include_router(auth.router, prefix="/api/v1")
app.include_router(admin.router, prefix="/api/v1")
@app.get("/health", tags=["System"])
async def health_check() -> dict:
return {"status": "ok"}
[Authorize(Roles = "Admin")] або [Authorize(Roles = "Admin,Manager")]. У FastAPI — Depends(require_roles(UserRole.ADMIN)). Концептуально ідентично, але FastAPI-підхід явніший: залежність видима прямо у сигнатурі функції, її легко замінити у тестах через app.dependency_overrides, і вона повністю типізована. ASP.NET [Authorize] — декларативна магія через reflection. FastAPI Depends — явна функціональна композиція.Включення ролі у JWT-токен
Оптимізація: замість завантаження User з БД лише для перевірки ролі — вбудовуємо роль прямо у Access JWT. Тоді при перевірці ролі БД не потрібна взагалі. Оновлюємо виклики create_access_token у auth.py (у функціях register та login):
# У register() та login() — додаємо роль у extra_claims:
access_token = create_access_token(
subject=str(user.id),
extra_claims={"role": user.role.value}, # "user" / "manager" / "admin"
)
admin на user, але той вже має живий access token з "role": "admin" — він матиме адмін-права ще до 15 хвилин (до закінчення exp). Це прийнятний trade-off для коротких токенів. Якщо ваша система потребує миттєвого відкликання ролей — завантажуйте User з БД при кожному запиті через get_current_user, а не читайте роль з токена.Матриця доступу TaskForge
| Ресурс / Дія | user | manager | admin |
|---|---|---|---|
| Читати/редагувати свої проєкти | ✅ | ✅ | ✅ |
| Читати проєкти команди | ❌ | ✅ | ✅ |
| Управляти задачами команди | ❌ | ✅ | ✅ |
| Переглядати звіти | ❌ | ✅ | ✅ |
| Переглядати всіх користувачів | ❌ | ❌ | ✅ |
| Змінювати ролі користувачів | ❌ | ❌ | ✅ |
| Деактивувати акаунти | ❌ | ❌ | ✅ |
Частина 5. API Key — автентифікація для сервіс-до-сервісу
Навіщо потрібна API Key автентифікація
JWT чудово підходить для автентифікації кінцевих користувачів через браузер чи мобайл. Але що, якщо до вашого API звертається інший сервіс — CI/CD пайплайн, скрипт моніторингу, зовнішній партнер? Такі клієнти не «логіняться» через форму — вони мають довгоживучий ідентифікатор, який передають у кожному запиті.
API Key — це довгий випадковий рядок (token), що ідентифікує клієнта. На відміну від JWT, він:
- Не має строку закінчення дії (відкликається явно через БД)
- Не містить вбудованих даних — лише lookup key
- Може мати обмежений scope (наприклад,
read:projectsбез права запису)
Два способи передачі API Key
GET /api/v1/projects HTTP/1.1
X-API-Key: tf_live_bX3R9qKm7Vn2pL8sA1jD5cW4uY6oE0tF
GET /api/v1/projects?api_key=tf_live_bX3R9qKm7Vn2pL8sA1jD5cW4uY6oE0tF
Реалізація API Key у FastAPI
Модель та репозиторій API Key:
# app/models/api_key.py
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# SHA-256 хеш API Key — лише для lookup, не зберігаємо відкрито
key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
# Prefix для ідентифікації у логах (перші 8 символів ключа): "tf_live_bX3R9qKm..."
key_prefix: Mapped[str] = mapped_column(String(16), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False) # опис: "CI/CD pipeline"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped["User"] = relationship("User") # type: ignore[name-defined]
# app/repositories/api_key.py
import hashlib
import secrets
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api_key import ApiKey
def generate_api_key(prefix: str = "tf_live") -> tuple[str, str]:
"""
Генерує API Key та повертає (plain_key, key_hash).
Формат: {prefix}_{random_urlsafe_32_bytes}
Наприклад: tf_live_bX3R9qKm7Vn2pL8sA1jD5cW4uY6oE0tF
Returns:
Кортеж (відкритий ключ для відправки клієнту, SHA-256 хеш для БД).
"""
raw = secrets.token_urlsafe(32)
plain_key = f"{prefix}_{raw}"
key_hash = hashlib.sha256(plain_key.encode()).hexdigest()
return plain_key, key_hash
class ApiKeyRepository:
def __init__(self, db: AsyncSession) -> None:
self._db = db
async def get_by_hash(self, key_hash: str) -> ApiKey | None:
result = await self._db.execute(
select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active == True)
)
return result.scalar_one_or_none()
async def create(
self,
user_id: uuid.UUID,
name: str,
plain_key: str,
key_hash: str,
) -> ApiKey:
api_key = ApiKey(
key_hash=key_hash,
key_prefix=plain_key[:16],
name=name,
user_id=user_id,
is_active=True,
created_at=datetime.now(timezone.utc),
)
self._db.add(api_key)
await self._db.commit()
await self._db.refresh(api_key)
return api_key
async def update_last_used(self, api_key: ApiKey) -> None:
api_key.last_used_at = datetime.now(timezone.utc)
await self._db.commit()
async def revoke(self, api_key_id: uuid.UUID) -> None:
key = await self._db.get(ApiKey, api_key_id)
if key:
key.is_active = False
await self._db.commit()
Dependency для API Key автентифікації:
# app/api/deps.py — додаємо нову dependency
import hashlib
from fastapi import Header, HTTPException, status
from app.db.session import get_db
from app.models.user import User
from app.repositories.api_key import ApiKeyRepository
async def get_user_from_api_key(
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
db: AsyncSession = Depends(get_db),
) -> User:
"""
Dependency: автентифікує через API Key з заголовка X-API-Key.
Алгоритм:
1. Витягує X-API-Key з заголовка запиту.
2. Обчислює SHA-256 хеш ключа.
3. Шукає ключ у БД за хешем.
4. Повертає пов'язаного User або 401.
"""
if x_api_key is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="X-API-Key header is required",
)
key_hash = hashlib.sha256(x_api_key.encode()).hexdigest()
api_key_repo = ApiKeyRepository(db)
api_key = await api_key_repo.get_by_hash(key_hash)
if api_key is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked API key",
)
# Оновлюємо last_used_at (у фоні, без блокування відповіді)
await api_key_repo.update_last_used(api_key)
return api_key.user
Ендпоінти управління API Keys:
# app/api/v1/api_keys.py
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user
from app.db.session import get_db
from app.models.user import User
from app.repositories.api_key import ApiKeyRepository, generate_api_key
router = APIRouter(prefix="/api-keys", tags=["API Keys"])
class ApiKeyCreateRequest(BaseModel):
name: str # Опис: "GitHub Actions CI", "Monitoring script"
class ApiKeyCreateResponse(BaseModel):
id: uuid.UUID
name: str
key_prefix: str
# plain_key показується ОДИН РАЗ — після цього відновити неможливо
plain_key: str
class ApiKeyListResponse(BaseModel):
id: uuid.UUID
name: str
key_prefix: str
is_active: bool
model_config = {"from_attributes": True}
@router.post("/", response_model=ApiKeyCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_api_key(
body: ApiKeyCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyCreateResponse:
"""
Створює новий API Key для поточного користувача.
ВАЖЛИВО: plain_key показується лише один раз у відповіді.
Збережіть його — відновити буде неможливо.
"""
plain_key, key_hash = generate_api_key(prefix="tf_live")
api_key = await ApiKeyRepository(db).create(
user_id=current_user.id,
name=body.name,
plain_key=plain_key,
key_hash=key_hash,
)
return ApiKeyCreateResponse(
id=api_key.id,
name=api_key.name,
key_prefix=api_key.key_prefix,
plain_key=plain_key, # єдиний раз — у відповіді на створення
)
@router.delete("/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_api_key(
api_key_id: uuid.UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Відкликає (деактивує) API Key."""
await ApiKeyRepository(db).revoke(api_key_id)
AuthenticationHandler або middleware. У FastAPI — через стандартний Depends() з функцією get_user_from_api_key. Підхід однаковий за суттю, але FastAPI-рішення більш яке і не вимагає реєстрації нової схеми автентифікації.Частина 6. Security Headers — захист через HTTP-заголовки
Що таке Security Headers і навіщо вони потрібні
Навіть ідеально написаний код може бути вразливим, якщо HTTP-відповіді не містять потрібних заголовків безпеки. Браузер — перша лінія захисту між вашим API та кінцевим користувачем. Правильні заголовки інструктують браузер, як обробляти відповідь, і запобігають цілому класу атак.
| Заголовок | Від чого захищає | Приклад значення |
|---|---|---|
Strict-Transport-Security | Атаки downgrade HTTPS → HTTP | max-age=31536000; includeSubDomains |
X-Content-Type-Options | MIME sniffing атаки | nosniff |
X-Frame-Options | Clickjacking через iframe | DENY |
Content-Security-Policy | XSS, injection атаки | default-src 'self' |
Referrer-Policy | Витік URL у Referer | strict-origin-when-cross-origin |
Permissions-Policy | Зловживання API браузера (камера, мікрофон) | camera=(), microphone=() |
Реалізація через Starlette Middleware
FastAPI побудований на Starlette, тому middleware для додавання заголовків — звичайний BaseHTTPMiddleware:
# app/middleware/security_headers.py
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""
Middleware для додавання HTTP Security Headers до кожної відповіді.
Захищає від:
- XSS (Content-Security-Policy)
- Clickjacking (X-Frame-Options)
- MIME sniffing (X-Content-Type-Options)
- Protocol downgrade (Strict-Transport-Security)
- Витоку даних через Referer (Referrer-Policy)
"""
async def dispatch(self, request: Request, call_next) -> Response:
response = await call_next(request)
# Примушує браузер завжди використовувати HTTPS протягом 1 року.
# includeSubDomains поширює правило на всі піддомени.
# preload — дозволяє включення до HSTS preload list браузерів.
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains; preload"
)
# Забороняє браузеру «вгадувати» MIME тип файлу.
# Без цього браузер міг би виконати JS замаскований под text/plain.
response.headers["X-Content-Type-Options"] = "nosniff"
# Забороняє відображення сторінки в iframe (захист від clickjacking).
# SAMEORIGIN дозволяє лише iframe з того ж домену.
response.headers["X-Frame-Options"] = "DENY"
# Базова CSP: дозволяє ресурси лише з власного домену.
# Для API (без HTML) достатньо мінімальної CSP.
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"frame-ancestors 'none'" # еквівалент X-Frame-Options DENY для CSP Level 3
)
# Обмежує витік URL у заголовку Referer.
# strict-origin-when-cross-origin: надсилає тільки origin (без path) для cross-origin.
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Вимикає доступ до браузерних API, що можуть бути зловживані.
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=(), payment=()"
)
# Забороняє Internet Explorer переходити в режим сумісності.
# Застарілий, але не шкодить.
response.headers["X-UA-Compatible"] = "IE=edge"
return response
CORS налаштування
CORS (Cross-Origin Resource Sharing) — механізм браузера, що обмежує запити до API з інших доменів. FastAPI має вбудований CORS middleware від Starlette:
# app/middleware/cors.py
from fastapi.middleware.cors import CORSMiddleware
# Налаштування для додавання у main.py
CORS_SETTINGS = {
"allow_origins": [
"https://taskforge.app", # production frontend
"https://staging.taskforge.app", # staging
"http://localhost:3000", # локальна розробка
"http://localhost:5173", # Vite dev server
],
"allow_credentials": True, # дозволяє передачу cookie та Authorization header
"allow_methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
"allow_headers": [
"Authorization",
"Content-Type",
"X-API-Key",
"X-Request-ID",
],
"expose_headers": [
"X-Request-ID", # клієнт бачить ID запиту для дебагу
],
"max_age": 600, # браузер кешує preflight відповідь на 10 хвилин
}
allow_origins=["*"] разом з allow_credentials=True.Це комбінація, яку браузер просто відхилить (CORS spec забороняє wildcard при credentials). Крім того, wildcard origins означає, що будь-який сайт у мережі може робити автентифіковані запити від імені вашого користувача — це і є CSRF атака.Rate Limiting — обмеження кількості запитів
Захист від брутфорсу та DDoS — обмеження кількості запитів з одного IP або для одного ендпоінта. Найпростіше рішення — бібліотека slowapi (адаптація flask-limiter для FastAPI/Starlette):
# app/middleware/rate_limit.py
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
# Використовуємо IP-адресу клієнта як ключ для лімітування
limiter = Limiter(key_func=get_remote_address)
# app/main.py — повна фінальна версія з усіма middleware
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from app.api.v1 import admin, api_keys, auth
from app.middleware.rate_limit import limiter
from app.middleware.security_headers import SecurityHeadersMiddleware
app = FastAPI(
title="TaskForge API",
description="Project management API — JWT + Opaque Refresh Token + RBAC",
version="0.1.0",
)
# ─── State (для slowapi) ──────────────────────────────────────────────────────
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# ─── Middleware (порядок важливий: виконуються у зворотному порядку) ──────────
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://taskforge.app",
"http://localhost:3000",
"http://localhost:5173",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-API-Key"],
max_age=600,
)
# ─── Роутери ──────────────────────────────────────────────────────────────────
app.include_router(auth.router, prefix="/api/v1")
app.include_router(admin.router, prefix="/api/v1")
app.include_router(api_keys.router, prefix="/api/v1")
@app.get("/health", tags=["System"])
async def health_check() -> dict:
return {"status": "ok"}
Тепер застосовуємо rate limiting до чутливих ендпоінтів:
# app/api/v1/auth.py — додаємо @limiter.limit до login та register
from app.middleware.rate_limit import limiter
@router.post("/login", response_model=TokenResponse)
@limiter.limit("5/minute") # не більше 5 спроб логіну з одного IP за хвилину
async def login(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
# ... (повна реалізація вже у Частині 3) ...
pass
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("3/hour") # не більше 3 реєстрацій з одного IP за годину
async def register(
request: Request,
data: UserRegisterRequest,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
# ... (повна реалізація вже у Частині 3) ...
pass
Встановлення slowapi:
pip install slowapi
uv add slowapi
poetry add slowapi
Практичні завдання
Рівень 1: Реєстрація та логін з JWT
Завдання: Запустіть повну реалізацію зі секції «Повна реалізація» та протестуйте через Swagger UI.
Кроки:
- Скопіюйте всі файли з секції
::code-tree. - Створіть
.envіз власнимSECRET_KEY(згенеруйте черезopenssl rand -hex 32). - Запустіть PostgreSQL та сервер.
- Відкрийте
/docs→ зареєструйтесь через/api/v1/auth/register. - Натисніть «Authorize» → вставте
access_token→ перевірте/health.
Критерій успіху: /register повертає access_token та refresh_token. Запит до захищеного ендпоінта з токеном повертає 200 OK, без токена — 401.
Рівень 2: Refresh Token Flow з ротацією
Завдання: Перевірте механізм ротації refresh token руками.
Кроки:
- Залогіньтесь → збережіть
refresh_token(назвемоRT1). - Зробіть запит
POST /api/v1/auth/refreshзRT1→ отримайтеRT2. - Знову зробіть
POST /api/v1/auth/refreshзRT1(вже використаним). - Переконайтесь, що відповідь —
401 "Refresh token not found or already used". - Перевірте таблицю
refresh_tokensу БД:
SELECT token_hash, user_id, expires_at, created_at FROM refresh_tokens;
Критерій успіху: Після ротації стара token_hash більше не існує у БД. Повторне використання старого токена — 401.
Рівень 3: RBAC-система з ролями
Завдання: Додайте роль ADMIN до свого акаунта та перевірте матрицю доступу.
Кроки:
- Додайте модуль
app/models/role.pyзUserRoleenum. - Оновіть
app/models/user.py— додайте полеrole. - Перестворіть таблиці (
python create_tables.py). - Вручну оновіть роль свого користувача у БД:
UPDATE users SET role = 'admin' WHERE email = 'your@email.com';
- Зареєструйте
admin.routerуmain.py. - Залогіньтесь → перевірте
GET /api/v1/admin/users— має повернути список. - Зареєструйте нового звичайного користувача і спробуйте
GET /api/v1/admin/usersз його токеном — має бути403.
Критерій успіху: Endpoint /admin/users доступний лише з токеном адміністратора. З токеном user-а — 403 Forbidden.
Підсумок
У цій статті ми побудували повноцінну production-ready систему автентифікації та авторизації для FastAPI. Ось що реалізовано:
Хешування паролів
bcrypt з work factor (rounds) 12. Попереднє обрізання до 72 байтів для запобігання ValueError. Порівняння bcrypt / Argon2 / SHA-256 з реальними числами атак.JWT Access Token
python-jose + HS256. Claims: sub, iat, exp, type. Короткий час життя (15 хв). Claim type=access — захист від підміни токена.Opaque Refresh Token
secrets.token_urlsafe(32) — 256 біт ентропії. SHA-256 хеш у БД. Ротація при кожному використанні. Збереження user_agent та ip_address для аудиту.OAuth2 + FastAPI
OAuth2PasswordBearer + OAuth2PasswordRequestForm. Ланцюжок залежностей: get_current_user → get_current_active_user. Swagger UI «Authorize» з одного налаштування.RBAC
UserRole(str, Enum) — USER/MANAGER/ADMIN. Dependency-фабрика require_roles(). Роль у JWT для оптимізації (з caveat про миттєве відкликання).API Key
tf_live_ prefix + SHA-256 хеш у БД. Відстеження last_used_at. X-API-Key header (не query param).Security Headers
SecurityHeadersMiddleware: HSTS, X-Content-Type-Options, X-Frame-Options, CSP. CORS з явним allow_origins. Rate limiting через slowapi (5/хв для login).Що залишилось за межами статті (для поглибленого вивчення)
- OAuth2 Authorization Code Flow — «Увійти через Google/GitHub». Бібліотека
authlib. - Asymmetric JWT (RS256/ES256) — RSA/EC ключові пари для мікросервісної архітектури де різні сервіси мають верифікувати токени, але не мають права їх видавати.
- Permission-based авторизація на рівні об'єкта — «чи може цей user редагувати саме цей проєкт?» Через
sqlalchemy+ перевіркуProjectMember.role. - Two-Factor Authentication (2FA/TOTP) —
pyotpбібліотека, Google Authenticator сумісність. - Audit Logging —
structlogдля структурованих логів автентифікаційних подій.
Repository Pattern та Clean Architecture у FastAPI
Принципи побудови масштабованих систем на FastAPI за допомогою чистої архітектури, патерну Репозиторій (Repository) та Unit of Work для ізоляції бізнес-логіки від деталей інфраструктури.
Тестування FastAPI — pytest, httpx, fixtures
Глибоке занурення у тестування FastAPI застосунків. Перехід від концепцій xUnit до pytest, налаштування середовища, unit-тестування Pydantic-схем та параметризовані тести.