FastAPI

Автентифікація та авторизація — JWT, OAuth2, RBAC

Глибоке занурення у безпеку FastAPI — хешування паролів, JSON Web Tokens, OAuth2 flow, RBAC на рівні проєкту та Security Headers. Порівняння з ASP.NET підходом для розробників, що мігрують на Python.

Автентифікація та авторизація — JWT, OAuth2, RBAC

Чому безпека є першим пріоритетом

Кожен застосунок, що обробляє дані користувачів, рано чи пізно зіткнеться з одним і тим самим фундаментальним питанням: хто ти такий і що тобі дозволено робити? Саме ці два питання формують два наріжні камені безпеки будь-якої системи — автентифікацію (authentication) та авторизацію (authorization).

На перший погляд, їхні назви схожі. Але різниця принципова:

Автентифікація (Authentication)

Процес підтвердження особи. Система перевіряє, що запитувач є саме тим, за кого себе видає. Класичний приклад — введення логіну та пароля. Відповідь на питання: «Хто ти?»

Авторизація (Authorization)

Процес перевірки прав доступу. Після того, як особа підтверджена, система вирішує, що їй дозволено робити. Відповідь на питання: «Що тобі можна?»

Типова послідовність виглядає так: спочатку система автентифікує — перевіряє логін і пароль, видає токен. Потім — при кожному наступному запиті — авторизує — перевіряє токен і вирішує, чи має цей користувач доступ до конкретного ресурсу.

Контекст для тих, хто прийшов з ASP.NET:В екосистемі ASP.NET Core ці концепції реалізовані через [Authorize] атрибут, middleware UseAuthentication() / UseAuthorization(), схеми JwtBearerDefaults.AuthenticationScheme, Identity framework та claims-based модель. У FastAPI підхід концептуально ідентичний, але реалізований через систему Dependency Injection (Depends()), Security Schemes та декоратори роутів. Цю статтю побудовано так, щоб виявити паралелі та відмінності між двома підходами.

Частина 1. Хешування паролів — фундамент безпеки

Чому не можна зберігати паролі відкритим текстом

Уявіть таку ситуацію: ваша база даних була скомпрометована — зловмисник отримав дамп таблиці users. Якщо паролі зберігалися відкритим текстом (або навіть у форматі Base64), він отримав одразу всі паролі всіх користувачів. Враховуючи, що більшість людей використовують один пароль на декількох сайтах, збитки будуть катастрофічними.

Правильне рішення — зберігати не сам пароль, а його криптографічний хеш. При вході користувач знову вводить пароль, система знову хешує його і порівнює два хеші. Якщо вони збігаються — пароль правильний.

Але не всі хеш-функції однаково безпечні. Розглянемо еволюцію підходів:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontSize 13

rectangle "❌ MD5 / SHA-1 / SHA-256\n(НЕ для паролів)" as bad #ffcccc {
  note as n1
    Призначені для швидкості
    GPU: мільярди хешів/секунду
    Rainbow tables атаки
    Не містять salt за замовчуванням
  end note
}

rectangle "✅ bcrypt / Argon2 / scrypt\n(Правильний вибір)" as good #ccffcc {
  note as n2
    Спеціально повільні (work factor)
    Вбудований унікальний salt
    Захист від GPU-атак
    Налаштована складність
  end note
}

bad -down-> good : Еволюція
@enduml

Ключова ідея: хеш-функції для паролів навмисно повільні. Це не баг — це фіча. Якщо перевірка одного пароля займає 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✅ дуже сильний
Зверніть увагу на колонку «Хешів/сек (RTX 4090)»: для SHA-256 GPU дає прискорення у ~44 рази відносно CPU. Для bcrypt — лише у ~13 разів. Для Argon2id — лише у ~1.5 рази. Саме ця різниця й робить спеціалізовані алгоритми практично невразливими до GPU-атак.

Щоб перетворити ці числа на реальне відчуття загрози, розглянемо сценарій атаки: зловмисник отримав ваш дамп БД і хоче зламати пароль P@ssw0rd! (9 символів, змішаний регістр + цифри + спецсимвол, ~3 квадрильйони комбінацій):

АлгоритмШвидкість GPU (RTX 4090)Час повного перебору
MD5300 млрд/сек~2.8 години
SHA-25622 млрд/сек~38 годин
bcrypt cost=1280/сек~1.2 мільярда років
Argon2id12/сек~7.9 мільярда років
Наведені часи для bcrypt та Argon2id — теоретичний максимум для одного GPU. На практиці зловмисники використовують словникові атаки з реальними паролями людей (типу password123, qwerty, iloveyou), а не повний перебір. Тому надійний, довгий та унікальний пароль — така ж важлива умова безпеки, як і правильний алгоритм хешування.

Salt: захист від таблиць передобчислень

Ще одна концепція, яку важливо розуміти — сіль (salt). Це випадковий рядок, який додається до пароля перед хешуванням. Кожен пароль отримує унікальний salt.

Навіщо це потрібно? Без salt два користувачі з однаковим паролем secret123 матимуть однаковий хеш у базі даних. Зловмисник це помітить і зрозуміє, що паролі однакові. Більше того, він може заздалегідь побудувати так звану rainbow table — таблицю відповідності «пароль → хеш» для мільйонів популярних паролів і миттєво знайти збіг.

Salt вирішує цю проблему: навіть якщо два користувачі мають однаковий пароль, їхні хеші будуть абсолютно різними, оскільки salt у кожного унікальний.

password
str
Оригінальний пароль користувача у відкритому вигляді. Ніколи не зберігається.
salt
bytes
Випадковий рядок байтів, що генерується при кожному хешуванні. Зберігається разом із хешем (зазвичай як префікс).
work_factor / cost
int
Параметр, що визначає обчислювальну складність операції хешування. Чим вище — тим повільніше і безпечніше. Для bcrypt типові значення: 10–12. Можна збільшувати з часом при зростанні потужностей CPU.
hash
str
Результат хешування: рядок, що містить у собі алгоритм, work factor, salt та власне хеш — все в одному. Зберігається у базі даних.

Хешування паролів у Python: перехід на чистий bcrypt

Історично стандартним вибором в екосистемі Python для роботи з паролями була бібліотека passlib (зокрема її клас CryptContext). Вона дозволяла зручно керувати багатьма алгоритмами.

Однак на сьогодні бібліотека passlib є фактично закинутою (unmaintained) і не оновлюється. Це призводить до серйозних проблем із сумісністю: при використанні нових версій бібліотеки bcrypt (від 4.0.0 і вище) виникає помилка: AttributeError: module 'bcrypt' has no attribute '__about__'

Тому сучасним рекомендованим підходом є використання офіційної бібліотеки bcrypt напряму.

pip install 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=12? Параметр rounds визначає обчислювальну складність операції хешування (кількість ітерацій дорівнює $2^{12} = 4096$). Значення 12 є оптимальним стандартом на сьогодні: воно займає близько 150-200 мс на сучасному процесорі, що ускладнює brute-force атаки на відеокартах, але є непомітним для користувача під час авторизації.

Спадкова альтернатива: використання passlib та CryptContext

Якщо у вашому проєкті вже використовується passlib і швидка міграція на прямий bcrypt неможлива, ви можете налаштувати контекст через CryptContext.

При виникненні помилки AttributeError: module 'bcrypt' has no attribute '__about__' у вас є два варіанти швидкого вирішення:
  1. Знизити версію bcrypt: встановити старішу версію бібліотеки (наприклад, pip install bcrypt==3.2.2). Цей варіант є тимчасовим фіксом, але не рекомендується з міркувань безпеки.
  2. Перейти на Argon2id: встановити argon2-cffi і використовувати схему "argon2".

Для ознайомлення нижче наведено опис роботи з CryptContext:

Клас CryptContext

Центральним об'єктом passlib є CryptContext — контекст, що інкапсулює вибір алгоритму, параметри та логіку верифікації.

CryptContext
клас passlib.context
Менеджер криптографічного контексту для хешування паролів. Підтримує кілька схем одночасно та автоматичне оновлення застарілих хешів.Імпорт:from passlib.context import CryptContext
schemes
list[str]
Список підтримуваних алгоритмів. Перший у списку є алгоритмом за замовчуванням для нових хешів. Приклад: ["bcrypt", "argon2"].
deprecated
str | list[str]
Алгоритми, що вважаються застарілими. Паролі, захешовані цими алгоритмами, будуть позначені як такі, що потребують перехешування. Значення "auto" автоматично позначає всі схеми, крім активної.
bcrypt\_\_rounds
int
Work factor для bcrypt. За замовчуванням 12. Кожна одиниця подвоює час обчислення. Для production рекомендовано 12–14.

Ключові методи CryptContext:

hash(secret)
метод → str
Хешує пароль secret з використанням активного алгоритму та автоматично генерованим salt. Повертає повний рядок хешу, що містить всю необхідну інформацію.
hashed = pwd_context.hash("mysecretpassword")
# Результат: "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW"
verify(secret, hash)
метод → bool
Перевіряє, чи відповідає пароль secret раніше збереженому хешу hash. Автоматично визначає алгоритм та параметри з самого рядка хешу.
is_valid = pwd_context.verify("mysecretpassword", hashed_from_db)
# True або False
verify_and_update(secret, hash)
метод → tuple[bool, str | None]
Розширена версія 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)
Порівняння з ASP.NET Identity:В ASP.NET Identity за хешування паролів відповідає клас 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).

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontSize 12
skinparam padding 8

title Алгоритм bcrypt: загальна схема

start

:Вхідні дані:
- password (до 72 байт)
- salt (128 біт / 16 байт)
- cost factor (N);

:EksBlowfishSetup(cost, salt, password)
────────────────────────────────
① Ініціалізація P-масиву (18 × 32-біт)
   та S-boxes (4 × 256 × 32-біт)
   константами числа π;

:② Розгортання ключа з паролем:
ExpandKey(state, 0, password)
— XOR P-масиву з байтами пароля;

:③ Дорогий цикл: 2^cost ітерацій
ExpandKey(state, 0, salt)
ExpandKey(state, 0, password)
— повторювати 2^N разів;

:④ Шифрування магічного рядка:
ctext = "OrpheanBeholderScryDoubt"
— 64-бітними блоками Blowfish
— 64 рази;

:Вихід: 24 байти (192 біти) хешу;

stop
@enduml

Зверніть увагу на крок ③: саме там знаходиться весь «секрет» повільності 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 символи) дадуть однаковий хеш.

Якщо ваш додаток дозволяє паролі довші за 72 символи і використовує bcrypt — повідомте користувачів про це обмеження. Практичне рішення: попередньо хешувати пароль через SHA-256 і передавати хеш у bcrypt. Але це ускладнює систему. Для нових проєктів розгляньте Argon2id, що не має такого обмеження.

Анатомія рядка хешу bcrypt

Результат хешування через bcrypt — це не просто набір байтів. Це самодостатній рядок, що кодує у собі всю інформацію, необхідну для верифікації. Зберігаючи цей рядок у базі даних, вам більше нічого не потрібно.

Розберемо приклад реального хешу:

$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontSize 13

rectangle "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW" as full #eeeeee

rectangle "<b>$2b$</b>\nВерсія алгоритму" as v #cce5ff
rectangle "<b>12</b>\nWork factor\n(cost = 12,\nтобто 2^12 = 4096 ітерацій)" as c #ccffcc
rectangle "<b>EixZaYVK1fsbw1ZfbX3OXe</b>\nSalt (22 символи Base64\n= 128 біт)" as s #fff3cc
rectangle "<b>PaWxn96p36WQoeG6Lruj3vjPGga31lW</b>\nХеш (31 символ Base64\n= 184 біти з 192)" as h #ffcccc

full -down-> v
full -down-> c
full -down-> s
full -down-> h
@enduml
$2b$
Версія алгоритму (4 символи)
Префікс, що ідентифікує алгоритм та його версію. Існують варіанти $2$ (оригінальний), $2a$ (виправлена специфікація 2011 р.), $2x$ / $2y$ (OpenBSD-специфічні виправлення) та $2b$рекомендована поточна версія, виправлена у 2014 р. Бібліотека passlib за замовчуванням генерує $2b$.
12
Work factor (2 символи)
Числова вартість — параметр N у формулі 2^N ітерацій. Зберігається у хеші, щоб при верифікації алгоритм знав, скільки ітерацій використовувалося при хешуванні. Дозволяє поступово збільшувати складність у майбутньому без інвалідації старих хешів.
EixZaYVK1fsbw1ZfbX3OXe
Salt (22 символи Radix-64)
Випадково згенерований 128-бітний salt, закодований у модифікованій Base64 (алфавіт ./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789). Рівно 22 символи = 132 біти, з яких реально використовуються 128 (останні 4 біти ігноруються через особливості кодування).
PaWxn96p36WQoeG6Lruj3vjPGga31lW
Хеш (31 символ Radix-64)
Власне криптографічний хеш — результат 64-разового Blowfish-шифрування магічного рядка OrpheanBeholderScryDoubt. 31 символ кодує 184 біти з 192 (останні 8 біт відкидаються). Саме цей сегмент порівнюється при верифікації.

Весь рядок разом має 60 символів. Це завжди фіксована довжина для bcrypt — незалежно від довжини вхідного пароля. Ця властивість сама по собі корисна: розмір стовпця у базі даних завжди відомий наперед (CHAR(60) або VARCHAR(60)).

Work factor: скільки обрати?

Вибір work factor — це баланс між безпекою та продуктивністю. Мета: щоб одне хешування займало ~100–250 мс на вашому production-сервері. Цього достатньо, щоб зробити перебір практично неможливим, але не занадто довго для легітимних користувачів.

Work factor (cost)Ітерацій (2^N)Час на сучасному CPUХешів/сек (атака)
101 024~40 мс~25/с
112 048~80 мс~12/с
124 096~160 мс~6/с ← рекомендовано
138 192~320 мс~3/с
1416 384~640 мс~1.5/с
1665 536~2.5 с~0.4/с
Правило: раз на кілька років переглядайте work factor. Оскільки CPU-потужності зростають, те, що вчора займало 150 мс, завтра займатиме 80 мс. Завдяки тому, що cost зберігається у хеші, ви можете плавно мігрувати: при наступному вході користувача перехешуйте його пароль з новим cost. 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 до нуля.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontSize 12

title Швидкість атак: bcrypt vs SHA-256 (cost=12)

rectangle "SHA-256" as sha #ffcccc {
  note as n1
    CPU: ~500 мільйонів хешів/сек
    GPU (RTX 4090): ~22 мільярди хешів/сек
    Час на злам 8-символьного пароля: хвилини
  end note
}

rectangle "bcrypt (cost=12)" as bc #ccffcc {
  note as n2
    CPU: ~6 хешів/сек
    GPU (RTX 4090): ~100–300 хешів/сек
    Час на злам 8-символьного пароля: роки
  end note
}

sha -right[hidden]-> bc
@enduml

Бібліотека argon2-cffi — переможець PHC

Якщо bcrypt є перевіреним вибором, то Argon2 — переможцем Password Hashing Competition 2015 і рекомендованим стандартом OWASP для нових систем. Він має три варіанти: argon2d (стійкий до GPU), argon2i (стійкий до side-channel атак) та argon2id (гібридний, рекомендований).

pip install 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...
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontSize 13

package "JWT Token" #f8f8f8 {

  rectangle "HEADER\n(Base64URL)" as h #cce5ff {
    note as nh
      {
        "alg": "HS256",
        "typ": "JWT"
      }
      ──────────────
      alg: алгоритм підпису
      HS256 = HMAC-SHA256
      RS256 = RSA-SHA256
    end note
  }

  rectangle "PAYLOAD\n(Base64URL)" as p #ccffcc {
    note as np
      {
        "sub": "user-uuid-123",
        "email": "user@example.com",
        "role": "admin",
        "iat": 1720000000,
        "exp": 1720003600
      }
      ──────────────
      sub: subject (ID користувача)
      iat: issued at (коли видано)
      exp: expiration (коли спливає)
    end note
  }

  rectangle "SIGNATURE" as s #ffcccc {
    note as ns
      HMAC_SHA256(
        base64url(header) + "." +
        base64url(payload),
        SECRET_KEY
      )
      ──────────────
      Підтверджує цілісність:
      будь-яка зміна payload
      інвалідує підпис
    end note
  }

  h -right-> p : "."
  p -right-> s : "."
}
@enduml
Base64URL — це варіант Base64, адаптований для URL: символи + замінені на -, / на _, і знаки = (padding) опускаються. Тому JWT можна безпечно передавати у HTTP-заголовках, query параметрах і cookie без URL-кодування.

Claims — твердження у Payload

Частина Payload містить набір пар ключ-значення, які називаються claims (твердження). JWT-стандарт визначає три категорії:

Registered claims (стандартні)
RFC 7519
Зарезервовані імена з чітко визначеною семантикою. Використання необов'язкове, але рекомендоване.
ClaimПовна назваЗначення
issIssuerХто видав токен (назва сервісу)
subSubjectПро кого токен (ID користувача)
audAudienceДля кого токен (назва клієнта)
expExpiration TimeUnix timestamp — коли токен спливає
nbfNot BeforeUnix timestamp — з якого моменту токен дійсний
iatIssued AtUnix timestamp — коли токен видано
jtiJWT IDУнікальний ідентифікатор токена (для blacklist)
Public claims (публічні)
опційні
Claims, зареєстровані в IANA JSON Web Token Claims Registry для уникнення конфліктів імен між різними системами. Наприклад: email, name, picture.
Private claims (приватні)
довільні
Будь-які власні поля, погоджені між сторонами. Наприклад: role, project_id, permissions. Використовуйте обережно — не кладіть чутливі дані (пароль, платіжні дані), бо Payload лише закодований Base64URL, але не зашифрований.
JWT не є зашифрованим (якщо ви не використовуєте JWE — JSON Web Encryption). Будь-хто, хто отримає ваш токен, може прочитати Payload, просто декодувавши Base64URL. Але змінити без секретного ключа — не зможе: підпис стане недійсним. Тому: ніколи не кладіть у Payload паролі, номери карток або іншу чутливу інформацію.

Access Token + Refresh Token — правильний flow

Використовувати лише один токен з довгим терміном дії — небезпечно: якщо його вкрадуть, зловмисник матиме доступ до системи тривалий час. Галузевий стандарт — двотокенна система:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontSize 12

actor "Клієнт\n(браузер/мобайл)" as client
participant "FastAPI\nAuth Service" as auth
database "База\nданих" as db

== Крок 1: Логін ==

client -> auth : POST /auth/login\n{email, password}
auth -> db : SELECT user WHERE email=...
db --> auth : user з hashed_password
auth -> auth : verify_password(password, hash)
auth -> auth : Генерує access_token (15хв)\n+ refresh_token (30 днів)
auth -> db : INSERT refresh_tokens\n(token_hash, user_id, expires_at)
auth --> client : {access_token, refresh_token}

== Крок 2: Захищені запити ==

client -> auth : GET /api/projects\nAuthorization: Bearer <access_token>
auth -> auth : Верифікує JWT підпис\nЧитає user_id з payload
note right: НЕ звертається до БД!\nВсе є у токені.
auth --> client : 200 OK + дані

== Крок 3: Оновлення (access_token спік) ==

client -> auth : POST /auth/refresh\n{refresh_token}
auth -> db : SELECT WHERE token_hash=...\nAND expires_at > NOW()
db --> auth : валідний refresh_token
auth -> db : DELETE старий refresh_token\n(ротація!)
auth -> auth : Генерує НОВІ access + refresh tokens
auth -> db : INSERT новий refresh_token
auth --> client : {new_access_token, new_refresh_token}

== Крок 4: Логаут ==

client -> auth : POST /auth/logout\n{refresh_token}
auth -> db : DELETE refresh_token
auth --> client : 204 No Content
@enduml

Чому два токени, а не один?

Access Token
короткоживучий (15 хв – 1 год)
Передається у кожному запиті через заголовок Authorization: Bearer <token>. Сервер перевіряє лише підпис — без звернення до БД. Саме через це він повинен бути короткоживучим: якщо токен вкрадуть, він швидко протухне.
Refresh Token
довгоживучий (7–30 днів)
Зберігається у базі даних (у хешованому вигляді). Використовується тільки для отримання нового access 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 плюс необхідність зберігати стан.

Використання JWT як refresh token без механізму відкликання — поширена вразливість. Зловмисник, що отримав такий токен, матиме доступ до видачі нових access token протягом 30 днів (або скільки там у вас 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 запису — токен стає недійсним моментально
  • Аудит сесій: можна показати користувачу список активних сесій (пристроїв) і дозволити завершити будь-яку
  • Виявлення крадіжки через ротацію: якщо хтось використав вже використаний токен — це сигнал компрометації, можна відкликати всі сесії цього користувача
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontSize 12

title Виявлення крадіжки refresh token через ротацію

actor "Легітимний\nкористувач" as user
actor "Зловмисник" as attacker
participant "FastAPI" as api
database "БД\nrefresh_tokens" as db

== Нормальна ротація ==

user -> api : POST /auth/refresh\n{token: "abc123"}
api -> db : SELECT WHERE token_hash = hash("abc123")
db --> api : знайдено, дійсний
api -> db : DELETE token "abc123"\nINSERT token "def456"
api --> user : {new_refresh: "def456", new_access: "..."}

== Атака: зловмисник використовує вкрадений старий токен ==

attacker -> api : POST /auth/refresh\n{token: "abc123"}
api -> db : SELECT WHERE token_hash = hash("abc123")
db --> api : ❌ НЕ знайдено\n(вже видалено при ротації!)
api -> api : ⚠️ Можлива крадіжка токена!\nВідкликати ВСІ сесії user_id
api -> db : DELETE ALL WHERE user_id = ...
api --> attacker : 401 Unauthorized
note over user, attacker
  Легітимний користувач також\n
  втрачає доступ — але безпечніше\n
  так, ніж дати зловмиснику доступ
end note
@enduml

Порівняльна таблиця: JWT vs Opaque refresh token

КритерійJWT Refresh TokenOpaque (випадковий)
Форматheader.payload.signatureВипадковий рядок (base64url)
Вбудовані даніТак (sub, exp, claims)Ні — лише lookup key
Зберігається у БДНе обов'язково (stateless)Так, завжди
ВерифікаціяПеревірка підпису (без БД)Пошук у БД за хешем
Миттєве відкликання❌ Ні (потрібен blacklist)✅ Так (DELETE рядка)
Аудит сесій❌ Важко✅ Легко (окремий рядок на сесію)
Виявлення крадіжки❌ Без blacklist — неможливо✅ Через ротацію
Навантаження на БДМенше1 запит при кожному refresh
Складність реалізаціїПростаСередня
Рекомендація OWASP⚠️ З blacklist✅ Preferred
Висновок для TaskForge: використовуємо opaque refresh token (випадковий рядок через 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 алгоритмів

Модуль jose.jwt

Центральний інтерфейс бібліотеки — модуль jose.jwt з двома ключовими функціями:

jwt.encode(claims, key, algorithm)
функція → str
Створює підписаний 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",
)
jwt.decode(token, key, algorithms)
функція → dict
Верифікує підпис JWT та повертає розкодований payload.Параметри:
  • token (str) — рядок токена.
  • key (str | bytes | dict) — секретний ключ (той самий, що при encode).
  • algorithms (list[str]) — список дозволених алгоритмів. Завжди передавайте явно — це захист від атаки «algorithm confusion».
  • options (dict, опційно) — додаткові опції верифікації.
  • audience (str, опційно) — очікуване значення aud claim.
Повертає: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 токенів.

Порівняння з ASP.NET Core:У ASP.NET JWT-токени генеруються через 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), де немає потреби у редиректах до стороннього провайдера.

FastAPI не реалізує повноцінний OAuth2-сервер (Authorization Server). Він лише використовує OAuth2-специфікований формат запиту для отримання токена та Bearer Token schema для його передачі. Якщо вам потрібен повноцінний OAuth2 Authorization Server — розгляньте окремі рішення: Keycloak, Auth0, або бібліотеку authlib.

OAuth2PasswordBearer — схема безпеки

OAuth2PasswordBearer — це FastAPI-об'єкт, що описує схему автентифікації для Swagger UI та витягує Bearer Token з заголовка Authorization.

OAuth2PasswordBearer
клас fastapi.security
Dependency-клас, що реалізує OAuth2 Password Bearer схему. При використанні як Depends() — витягує JWT з заголовка Authorization: Bearer <token>. Якщо заголовок відсутній — автоматично повертає 401 Unauthorized.Імпорт:from fastapi.security import OAuth2PasswordBearer
tokenUrl
str (обов'язковий)
URL ендпоінта, на якому можна отримати токен. Використовується виключно для Swagger UI — щоб кнопка «Authorize» знала, куди відправляти логін і пароль. На логіку верифікації токена не впливає.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
auto_error
bool = True
Якщо 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 для цього.

OAuth2PasswordRequestForm
клас fastapi.security
Dependency-клас, що парсить application/x-www-form-urlencoded тіло запиту та повертає об'єкт з полями форми OAuth2.Імпорт:from fastapi.security import OAuth2PasswordRequestForm
username
str
Поле форми username. За OAuth2 специфікацією ім'я поля — саме username, навіть якщо ваша система використовує email. У реалізації ви можете шукати користувача за email, прийнявши username як email.
password
str
Поле форми password — пароль у відкритому вигляді.
grant_type
str | None
Тип запиту. Для Password Flow має бути "password". FastAPI валідує це автоматично.
scopes
list[str]
Список OAuth2 scope (прав доступу). Наприклад: ["read:projects", "write:tasks"]. Опційно, за замовчуванням — порожній список.

Ланцюжок авторизації: Depends(get_current_user)

Центральна концепція авторизації у FastAPI — ланцюжок залежностей. Замість атрибута [Authorize] на контролері (як в ASP.NET), у FastAPI авторизація реалізується через Depends(). Це робить процес явним, тестованим і гнучким.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontSize 12

title Ланцюжок залежностей авторизації FastAPI

participant "HTTP\nRequest" as req
participant "oauth2_scheme\n(OAuth2PasswordBearer)" as scheme
participant "get_current_user()" as gcu
participant "get_current_active_user()" as gcau
participant "Route Handler\n(ваш ендпоінт)" as handler
database "БД" as db

req -> scheme : Authorization: Bearer eyJ...
note right of scheme: витягує token string\nз заголовка

scheme -> gcu : token: str
gcu -> gcu : decode_access_token(token)\n→ payload dict
gcu -> db : SELECT * FROM users\nWHERE id = payload["sub"]
db --> gcu : User object
gcu -> gcau : user: User

gcau -> gcau : if user.is_active == False:\n  raise 403 Forbidden

gcau -> handler : current_user: User
handler --> req : 200 OK + response data
@enduml

Ось реалізація цього ланцюжка:

# 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),
    # ...
):
    ...
Порівняння з ASP.NET Core:В ASP.NET авторизація — атрибут: [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)
Зверніть увагу на коментар про timing attack у ендпоінті /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

Усі файли

Запуск

Запустити 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 вбудовує її у граф залежностей автоматично.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontSize 12

title Dependency Factory: require_roles()

rectangle "require_roles(ADMIN)" as call #e8f4ff {
  note as n1
    Виконується один раз при старті.
    Створює closure із:
    allowed = {UserRole.ADMIN}
    Повертає role_checker.
  end note
}

rectangle "role_checker (closure)" as checker #fff3e0 {
  note as n2
    Виконується при КОЖНОМУ запиті:
    1. Depends(get_current_active_user)
       -> User з БД
    2. if user.role not in allowed:
         raise 403 Forbidden
    3. return user
  end note
}

call --> checker : повертає (один раз)
checker --> checker : FastAPI викликає при кожному запиті
@enduml
# 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"}
Порівняння з ASP.NET Core:В ASP.NET перевірка ролей — атрибут: [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"
)
Роль у токені vs актуальний стан у БД.Якщо адміністратор змінив роль користувача з admin на user, але той вже має живий access token з "role": "admin" — він матиме адмін-права ще до 15 хвилин (до закінчення exp). Це прийнятний trade-off для коротких токенів. Якщо ваша система потребує миттєвого відкликання ролей — завантажуйте User з БД при кожному запиті через get_current_user, а не читайте роль з токена.

Матриця доступу TaskForge

Ресурс / Діяusermanageradmin
Читати/редагувати свої проєкти
Читати проєкти команди
Управляти задачами команди
Переглядати звіти
Переглядати всіх користувачів
Змінювати ролі користувачів
Деактивувати акаунти

Частина 5. API Key — автентифікація для сервіс-до-сервісу

Навіщо потрібна API Key автентифікація

JWT чудово підходить для автентифікації кінцевих користувачів через браузер чи мобайл. Але що, якщо до вашого API звертається інший сервіс — CI/CD пайплайн, скрипт моніторингу, зовнішній партнер? Такі клієнти не «логіняться» через форму — вони мають довгоживучий ідентифікатор, який передають у кожному запиті.

API Key — це довгий випадковий рядок (token), що ідентифікує клієнта. На відміну від JWT, він:

  • Не має строку закінчення дії (відкликається явно через БД)
  • Не містить вбудованих даних — лише lookup key
  • Може мати обмежений scope (наприклад, read:projects без права запису)

Два способи передачі API Key

HTTP Header (рекомендовано)
X-API-Key: <key>
Стандартний підхід. Заголовок не потрапляє у логи сервера (тіло запиту та заголовки часто фільтруются), не кешується браузером і проксі.
GET /api/v1/projects HTTP/1.1
X-API-Key: tf_live_bX3R9qKm7Vn2pL8sA1jD5cW4uY6oE0tF
Query Parameter (не рекомендовано)
?api_key=<key>
Простіший для тестування у браузері, але небезпечний: URL потрапляє у логи сервера, браузерну історію, Referer-заголовки.
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)
Порівняння з ASP.NET Core:В ASP.NET API Key автентифікація реалізується через кастомний AuthenticationHandler або middleware. У FastAPI — через стандартний Depends() з функцією get_user_from_api_key. Підхід однаковий за суттю, але FastAPI-рішення більш яке і не вимагає реєстрації нової схеми автентифікації.

Частина 6. Security Headers — захист через HTTP-заголовки

Що таке Security Headers і навіщо вони потрібні

Навіть ідеально написаний код може бути вразливим, якщо HTTP-відповіді не містять потрібних заголовків безпеки. Браузер — перша лінія захисту між вашим API та кінцевим користувачем. Правильні заголовки інструктують браузер, як обробляти відповідь, і запобігають цілому класу атак.

ЗаголовокВід чого захищаєПриклад значення
Strict-Transport-SecurityАтаки downgrade HTTPS → HTTPmax-age=31536000; includeSubDomains
X-Content-Type-OptionsMIME sniffing атакиnosniff
X-Frame-OptionsClickjacking через iframeDENY
Content-Security-PolicyXSS, injection атакиdefault-src 'self'
Referrer-PolicyВитік URL у Refererstrict-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

Практичні завдання

Рівень 1: Реєстрація та логін з JWT

Завдання: Запустіть повну реалізацію зі секції «Повна реалізація» та протестуйте через Swagger UI.

Кроки:

  1. Скопіюйте всі файли з секції ::code-tree.
  2. Створіть .env із власним SECRET_KEY (згенеруйте через openssl rand -hex 32).
  3. Запустіть PostgreSQL та сервер.
  4. Відкрийте /docs → зареєструйтесь через /api/v1/auth/register.
  5. Натисніть «Authorize» → вставте access_token → перевірте /health.

Критерій успіху: /register повертає access_token та refresh_token. Запит до захищеного ендпоінта з токеном повертає 200 OK, без токена — 401.

Рівень 2: Refresh Token Flow з ротацією

Завдання: Перевірте механізм ротації refresh token руками.

Кроки:

  1. Залогіньтесь → збережіть refresh_token (назвемо RT1).
  2. Зробіть запит POST /api/v1/auth/refresh з RT1 → отримайте RT2.
  3. Знову зробіть POST /api/v1/auth/refresh з RT1 (вже використаним).
  4. Переконайтесь, що відповідь — 401 "Refresh token not found or already used".
  5. Перевірте таблицю refresh_tokens у БД:
SELECT token_hash, user_id, expires_at, created_at FROM refresh_tokens;

Критерій успіху: Після ротації стара token_hash більше не існує у БД. Повторне використання старого токена — 401.

Рівень 3: RBAC-система з ролями

Завдання: Додайте роль ADMIN до свого акаунта та перевірте матрицю доступу.

Кроки:

  1. Додайте модуль app/models/role.py з UserRole enum.
  2. Оновіть app/models/user.py — додайте поле role.
  3. Перестворіть таблиці (python create_tables.py).
  4. Вручну оновіть роль свого користувача у БД:
UPDATE users SET role = 'admin' WHERE email = 'your@email.com';
  1. Зареєструйте admin.router у main.py.
  2. Залогіньтесь → перевірте GET /api/v1/admin/users — має повернути список.
  3. Зареєструйте нового звичайного користувача і спробуйте 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_userget_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 Loggingstructlog для структурованих логів автентифікаційних подій.
Copyright © 2026