Network Programming

HTTP Advanced — cookies, аутентифікація та HTTPS

Глибоке вивчення механізмів стану в HTTP — cookies, атаки XSS/CSRF/session-fixation, Basic/Digest/Bearer аутентифікація, JWT алгоритми, OAuth 2.0 з усіма grant-types, PKCE, TLS Handshake, HSTS, HTTP Security Headers, кешування ETag/Last-Modified/stale-while-revalidate, CORS з credentialed-запитами та content negotiation.

HTTP Advanced — cookies, аутентифікація та HTTPS

Від теорії до практики: проблема stateless

У попередньому розділі ми з'ясували, що HTTP є stateless протоколом — сервер не зберігає жодної інформації між запитами. Кожен запит є ізольованим і повністю незалежним від попередніх.

Ця властивість забезпечує видатну масштабованість: будь-який сервер у кластері може обробити будь-який запит, не знаючи нічого про попередні взаємодії з клієнтом. Але вона ж породжує фундаментальну проблему реального вебу.

Уявіть сценарій: користувач вводить логін і пароль. Наступний запит — відкриває особистий кабінет. Ще один — оформлює замовлення. Як сервер знає, що всі три запити — від одного і того самого аутентифікованого користувача, якщо HTTP не зберігає стан?

Ключова ідея цього розділу: Stateless протокол + механізми передачі стану (cookies, токени) = реальний вебзастосунок. Всі механізми «стану» в HTTP — це лише угоди між клієнтом і сервером про передачу певного ідентифікатора з кожним запитом.

Існує три принципово різних підходи до вирішення проблеми стану:

МеханізмДе зберігається ідентифікаторХто надсилаєЗахист
Session CookieБраузер (cookie jar)Браузер автоматичноHttpOnly, Secure, SameSite
Bearer Token (JWT)Застосунок (memory/storage)Вручну в заголовкуHTTPS, short expiry
URL-параметрURL-рядокВручну❌ Логується, кешується
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff

actor "Користувач" as user
participant "Браузер" as browser #e3f2fd
participant "Сервер" as server #e8f5e9

== Запит 1: Вхід ==
user -> browser : Введено логін і пароль
browser -> server : POST /login\n{"user":"alice","pass":"..."}
server --> browser : 200 OK\nSet-Cookie: session=abc123

note right of server
  Сервер запам'ятав:
  session "abc123" → Alice
end note

== Запит 2: Особистий кабінет ==
browser -> server : GET /dashboard\nCookie: session=abc123
server --> browser : 200 OK\n<html>Вітаємо, Alice!</html>

note right of server
  Сервер впізнав Alice
  по session cookie
end note

== Без cookies (stateless!) ==
browser -> server : GET /dashboard\n(без Cookie)
server --> browser : 401 Unauthorized

note right of server
  Хто це? Невідомо.
  Stateless = немає пам'яті.
end note

@enduml

Cookies: механізм передачі стану

Cookie (HTTP Cookie, RFC 6265) — це невеликий фрагмент даних, який сервер надсилає браузеру у заголовку Set-Cookie, і браузер автоматично відправляє назад з кожним наступним запитом до того самого домену через заголовок Cookie.

Cookies — це не безпечне сховище і не база даних. Це лише механізм, що дозволяє серверу «наклеїти мітку» на браузер клієнта і потім розпізнавати його у наступних запитах.

Браузер зберігає cookies у cookie jar — приватній базі даних. Обмеження за специфікацією RFC 6265:

  • Не більше 4096 байт на один cookie (ім'я + значення + атрибути)
  • Не більше 50 cookies на домен
  • Не більше 3000 cookies загалом у браузері
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff

rectangle "Set-Cookie Header" #fff3e0 {
    rectangle "Name=Value\nsession=abc123xyz" as nv #ffe0b2
    rectangle "Domain\n.example.com" as domain #e3f2fd
    rectangle "Path\n/" as path #e3f2fd
    rectangle "Max-Age\n3600" as maxage #e8f5e9
    rectangle "Secure" as secure #fce4ec
    rectangle "HttpOnly" as httponly #fce4ec
    rectangle "SameSite\nLax" as samesite #f3e5f5

    nv -[hidden]right-> domain
    domain -[hidden]right-> path
    path -[hidden]right-> maxage
    maxage -[hidden]down-> secure
    secure -[hidden]right-> httponly
    httponly -[hidden]right-> samesite
}

note bottom of secure
  Лише по HTTPS.
  Захист від
  перехоплення.
end note

note bottom of httponly
  Недоступний через
  document.cookie.
  Захист від XSS.
end note

note bottom of samesite
  Strict / Lax / None.
  Захист від CSRF.
end note

@enduml

Повний приклад заголовку Set-Cookie:

Set-Cookie: session=abc123xyz; Domain=.example.com; Path=/; Max-Age=3600; Secure; HttpOnly; SameSite=Lax
Name=Value
string (обов'язково)
Ім'я та значення cookie. Ім'я не може містити спеціальні символи (пробіли, коми, крапки з комою). Значення може бути довільним рядком, але на практиці обмежується ~4096 байтами.
Domain
string
Домен, для якого дійсний cookie. Domain=.example.com — для всіх піддоменів (api.example.com, shop.example.com). Якщо не вказано — лише для поточного хоста (без піддоменів).
Path
string
URL-шлях, для якого надсилається cookie. Path=/ — для всього сайту. Path=/admin — лише для запитів до /admin/*.
Max-Age
секунди
Час життя cookie у секундах. Max-Age=3600 — 1 година. Max-Age=0 — негайне видалення. Має пріоритет над Expires. Якщо не вказано — session cookie: видаляється при закритті браузера.
Expires
HTTP-date
Альтернатива Max-Age: абсолютна дата закінчення. Expires=Thu, 01 Jan 2026 00:00:00 GMT. Застаріла альтернатива Max-Age, але широко підтримується.
Secure
flag
Cookie надсилається лише через HTTPS. Критично важливо для будь-яких cookies, що містять чутливі дані (session ID, токени). У HTTP-з'єднанні такий cookie ігнорується.
HttpOnly
flag
Cookie недоступний через JavaScript (document.cookie). Захищає від атак XSS (Cross-Site Scripting): навіть якщо зловмисник впровадив скрипт на сторінку, він не зможе вкрасти session cookie.
SameSite
Strict | Lax | None
Контролює, чи надсилається cookie при cross-site запитах (захист від CSRF):
  • Strict — лише при навігації з того самого сайту (максимальний захист)
  • Lax — дозволяє при top-level навігації (GET), блокує при cross-site POST (баланс)
  • None; Secure — завжди (для сторонніх виджетів, потребує Secure) ::

RFC 8941 визначає Cookie Prefixes — конвенцію іменування, що дозволяє браузеру примусово перевіряти атрибути безпеки при встановленні cookie. Якщо атрибути не відповідають вимогам префіксу, браузер відхиляє cookie.

ПрефіксОбов'язкові атрибутиПризначення
__Secure-Secure + доставка лише по HTTPSЗахист від downgrade атак
__Host-Secure + Path=/ + без DomainПрив'язка до конкретного хоста
Set-Cookie: __Secure-token=abc123; Secure; SameSite=Lax
Set-Cookie: __Host-session=xyz; Secure; Path=/; SameSite=Lax; HttpOnly

__Host- — найбезпечніший варіант для session cookies: неможливо передати на піддомен, неможливо встановити без HTTPS, завжди для кореневого шляху /.

Для всіх session cookies використовуйте __Host- префікс. Це запобігає атаці cookie tossing — коли зловмисник, що контролює піддомен evil.example.com, встановлює cookie для батьківського домену example.com.

SameSite у деталях

Важливе розрізнення: siteorigin. SameSite перевіряє реєстрований домен (eTLD+1), а не повний origin.

https://app.example.com та https://api.example.com — це same-site (обидва example.com), але cross-origin (різні піддомени).

Сценарій запитуStrictLaxNone
Top-level навігація GET (клік по посиланню)
Top-level навігація POST (submit форми)
Cross-site <img src=...>, <script src=...>
Cross-site fetch() / XHR
<iframe> cross-site
Same-site будь-який
SameSite=Noneвимагає атрибут Secure. Без нього браузери (Chrome 80+) відхиляють cookie. Також SameSite=None дозволяє cross-site запити — використовуйте лише для сторонніх виджетів (iframe, кросдоменні API).

Атаки на cookies та механізми захисту

Захист: HttpOnly атрибут повністю блокує доступ до cookie через document.cookie. Навіть при наявності XSS-вразливості зловмисник не зможе прочитати HttpOnly cookie.

Також: Content-Security-Policy (CSP) обмежує виконання inline-скриптів і завантаження ресурсів із сторонніх джерел.

Cross-Site Request Forgery (CSRF) — змушує браузер жертви надіслати автентифікований запит до цільового сайту. Браузер автоматично додає cookies до будь-якого запиту на відповідний домен, незалежно від ініціатора.

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

actor "Жертва\n(залогінена у банку)" as victim
participant "Шкідлива сторінка\nevil.com" as evil #fce4ec
participant "Банк API\nbank.com" as bank #e8f5e9

victim -> evil : GET /prize.html
evil --> victim : <form action="https://bank.com/transfer"\n  method="POST">\n  <input name="to" value="evil">\n  <input name="amount" value="5000">\n</form>\n<script>document.forms[0].submit()</script>

victim -> bank : POST /transfer\n  to=evil&amount=5000\n  Cookie: session=abc123 (автоматично!)

note right of bank
  Сервер бачить валідну сесію.
  Не може відрізнити CSRF
  від легітимного запиту
  без CSRF-токена.
end note

bank -> bank : 💸 Виконати переказ 5000₴

@enduml

Захист:

  • SameSite=Lax або Strict — найефективніший захист на рівні cookies
  • CSRF-токен — прихований токен у формі, що перевіряється сервером (для старих браузерів без SameSite)
  • Origin/Referer заголовки перевірка — додатковий захист

Зловмисник встановлює жертві відомий йому session ID ще до аутентифікації. Після того, як жертва логіниться з цим ID, зловмисник використовує ту саму сесію.

1. Зловмисник: GET /  → отримує session=known_id
2. Зловмисник надсилає жертві:
   https://bank.com/login?session=known_id
3. Жертва переходить, логіниться
4. Сервер НЕ змінює session ID → сесія known_id тепер авторизована
5. Зловмисник: GET /account, Cookie: session=known_id → має повний доступ!

Захист: Завжди генерувати новий session ID після успішного логіну (RegenerateId()). Це єдине ефективне рішення — зробити ID, відомий зловмиснику, непридатним після аутентифікації.

Якщо зловмисник контролює будь-який піддомен (evil.example.com), він може встановити cookie для батьківського домену .example.com, перезаписавши легітимний session cookie.

Set-Cookie: session=malicious; Domain=.example.com; Path=/

Браузер перешле цей cookie на bank.example.com поруч із легітимним. Якщо сервер бере перший cookie без перевірки, він обробить шкідливий.

Захист: __Host- префікс: cookie прив'язується до конкретного хоста, і браузер відхиляє його встановлення з будь-якого піддомену.

При передачі cookie по незашифрованому HTTP зловмисник у тій самій мережі (публічний Wi-Fi) може перехопити session ID через packet sniffing.

Жертва: GET http://example.com/ Cookie: session=abc123
Зловмисник (Wi-Fi) перехоплює: session=abc123
Зловмисник: GET /account Cookie: session=abc123 → доступ!

Захист: Secure атрибут — браузер надсилає cookie виключно по HTTPS. HSTS — примусова HTTPS-навігація (навіть якщо користувач вводить http://).

::


Cookies у C#

Читання та встановлення cookies

using System.Net;
using System.Net.Http;
using System.Net.Http.Json;

// CookieContainer автоматично зберігає і надсилає cookies
var cookieContainer = new CookieContainer();
var handler = new HttpClientHandler
{
    CookieContainer = cookieContainer,
    UseCookies = true
};

using var client = new HttpClient(handler)
{
    BaseAddress = new Uri("https://httpbingo.org/")
};

// httpbingo.org/cookies/set — встановлює cookie через 302-редирект
// CookieContainer автоматично слідує за редиректом і зберігає Set-Cookie
// GET /cookies/set?session=abc123xyz → 302 Location:/cookies + Set-Cookie: session=abc123xyz; HttpOnly
HttpResponseMessage loginResponse = await client.GetAsync("cookies/set?session=abc123xyz");
loginResponse.EnsureSuccessStatusCode();

// Переглянемо отримані cookies
IEnumerable<Cookie> cookies = cookieContainer.GetCookies(
    new Uri("https://httpbingo.org/")
);

foreach (Cookie cookie in cookies)
{
    Console.WriteLine($"Cookie: {cookie.Name}={cookie.Value}");
    Console.WriteLine($"  HttpOnly: {cookie.HttpOnly}");
    Console.WriteLine($"  Secure:   {cookie.Secure}");
    Console.WriteLine($"  Expires:  {cookie.Expires}");
    Console.WriteLine($"  Domain:   {cookie.Domain}");
}

// 2. Наступний запит — CookieContainer автоматично надішле Cookie header
// httpbingo.org/cookies повертає {"cookies":{"session":"abc123xyz"}}
HttpResponseMessage profileResponse = await client.GetAsync("cookies");
// Заголовок Cookie: session=abc123xyz буде додано автоматично

Cookie — це лише ключ. Реальний стан (хто такий користувач, що він може робити) зберігається на сервері. Класична схема — Session ID через Cookie.

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

actor "Браузер" as browser
participant "Веб-сервер" as server #e3f2fd
database "Session Store\n(Redis / In-Memory)" as store #e8f5e9

== Аутентифікація ==
browser -> server : POST /login\nCredentials
server -> store : SET session:abc123\n{userId:42, roles:["user"]}\nEX 3600
server --> browser : 200 OK\nSet-Cookie: __Host-session=abc123;\nHttpOnly; Secure; SameSite=Lax

== Наступні запити ==
browser -> server : GET /orders\nCookie: __Host-session=abc123
server -> store : GET session:abc123
store --> server : {userId:42, roles:["user"]}
server --> browser : 200 OK\n[список замовлень]

== Вихід ==
browser -> server : POST /logout\nCookie: __Host-session=abc123
server -> store : DEL session:abc123
server --> browser : 200 OK\nSet-Cookie: __Host-session=; Max-Age=0; Secure

@enduml
Горизонтальне масштабування та сесії: Session зберігається на одному сервері. Якщо наступний запит потрапить на інший сервер — сесія не знайдена (помилка 401). Рішення: Centralized session store (Redis) — найправильніший підхід, або Sticky sessions (балансувальник завжди направляє до одного сервера — погано для відмовостійкості).

Сесії vs Токени: порівняння архітектур

Два фундаментально різних підходи до збереження стану автентифікації:

КритерійSession CookieJWT / Bearer Token
Де станНа сервері (DB/Redis)У самому токені (claims)
Розмір~30 байт (ID)~300–600 байт (base64)
ВідкликанняМиттєво (DEL session:id)Складно (blacklist або short TTL)
МасштабуванняПотребує centralized storeБез серверного стану (stateless)
МікросервісиСкладніше — потрібен storeПростіше — JWT перевіряється локально
Безпека зберіганняHttpOnly cookie (XSS-стійкий)Залежить від клієнта
CSRFВразливий (потребує SameSite/токен)Не вразливий (header, не cookie)

HTTP-аутентифікація

Аутентифікація — це підтвердження особи клієнта. HTTP пропонує кілька схем, кардинально різних за безпекою та складністю.

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

rectangle "HTTP Authentication Schemes" #f5f5f5 {

    rectangle "Basic Auth\n(RFC 7617)" as basic #fce4ec {
        rectangle "Base64(login:password)" as b64 #f48fb1
    }

    rectangle "Digest Auth\n(RFC 7616)" as digest #fff9c4 {
        rectangle "MD5/SHA hash + nonce" as md5 #ffe082
    }

    rectangle "Bearer Token\n(RFC 6750)" as bearer #e8f5e9 {
        rectangle "JWT або opaque token" as jwt #a5d6a7
    }

    rectangle "OAuth 2.0\n(RFC 6749)" as oauth #e3f2fd {
        rectangle "Access Token" as at #90caf9
        rectangle "Refresh Token" as rt #90caf9
        at -[hidden]right-> rt
    }

    rectangle "API Key\n(нестандартна)" as apikey #f3e5f5 {
        rectangle "X-API-Key header" as xapi #ce93d8
    }
}

note bottom of basic
  ⚠ ЛИШЕ через HTTPS!
  Base64 ≠ шифрування.
end note

note bottom of bearer
  ✅ Стандарт для REST API.
  JWT — самодостатній токен.
end note

note bottom of oauth
  ✅ Делегована авторизація.
  Не передає пароль.
end note

@enduml

WWW-Authenticate: стандартний challenge flow

Перш ніж розглядати схеми, розберемо, як HTTP стандартизує запит і підтвердження автентифікації через заголовки WWW-Authenticate та Authorization.

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

participant "Клієнт" as client #e3f2fd
participant "Сервер" as server #e8f5e9

== Запит без автентифікації ==
client -> server : GET /protected HTTP/1.1\nHost: api.example.com

server --> client : HTTP/1.1 401 Unauthorized\nWWW-Authenticate: Bearer realm="api.example.com"\nWWW-Authenticate: Basic realm="api.example.com"

note over client
  Клієнт бачить 401 і
  знає, які схеми підтримує сервер.
  Вибирає Bearer і надсилає токен.
end note

== Повторний запит з токеном ==
client -> server : GET /protected HTTP/1.1\nAuthorization: Bearer eyJhbGc...

server --> client : HTTP/1.1 200 OK\n\n{"data": "..."}

@enduml

Сервер може оголосити кілька схем у відповіді 401. Клієнт обирає схему, яку підтримує, і повторює запит з відповідним заголовком Authorization.

Basic Authentication

Найпростіша схема. Логін і пароль кодуються у Base64 і передаються в заголовку Authorization:

alice:s3cr3t_p@ssw0rd
→ Base64 →
YWxpY2U6czNjcjN0X3BAc3N3MHJk

Authorization: Basic YWxpY2U6czNjcjN0X3BAc3N3MHJk
Base64 — це НЕ шифрування! Будь-хто, хто перехопить заголовок, миттєво декодує логін і пароль командою echo "YWxpY2U6czNjcjN0X3BAc3N3MHJk" | base64 -d. Basic Auth допустимий виключно через HTTPS. Ніколи не використовуйте Basic Auth по незашифрованому HTTP.
using System.Net.Http.Headers;
using System.Text;

// httpbingo.org/basic-auth/{user}/{password} — реальна перевірка Basic Auth
// Повертає 401 при невірних даних, 200 при правильних
using var client = new HttpClient
{
    BaseAddress = new Uri("https://httpbingo.org/")
};

// Кодуємо "alice:secret" у Base64
string credentials = Convert.ToBase64String(
    Encoding.UTF8.GetBytes("alice:secret")
);

// Варіант 1: на рівні клієнта (для всіх запитів)
// GET /basic-auth/alice/secret → перевіряє Base64("alice:secret") у заголовку
client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Basic", credentials);

// Варіант 2: на рівні одного запиту
var request = new HttpRequestMessage(HttpMethod.Get, "basic-auth/alice/secret");
request.Headers.Authorization =
    new AuthenticationHeaderValue("Basic", credentials);

HttpResponseMessage response = await client.SendAsync(request);

// Варіант 3: через NetworkCredential
var handler = new HttpClientHandler
{
    Credentials = new NetworkCredential("alice", "secret")
};
using var credClient = new HttpClient(handler);

Digest Authentication

Digest Auth (RFC 7616) — вдосконалення Basic, де пароль ніколи не передається відкрито. Замість цього клієнт відповідає на challenge сервера хешем.

1. Клієнт → GET /private
2. Сервер → 401 WWW-Authenticate: Digest realm="example.com",
              nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
              qop="auth"
3. Клієнт обчислює:
   HA1 = MD5("alice:example.com:password")
   HA2 = MD5("GET:/private")
   response = MD5(HA1:nonce:nc:cnonce:qop:HA2)
4. Клієнт → GET /private
   Authorization: Digest username="alice",
     realm="example.com", nonce="dcd98b...",
     response="6629fae49393a05397450978507c4ef1"

nonce — одноразове значення від сервера, що запобігає replay attacks: зловмисник не може повторно використати перехоплену відповідь, бо nonce змінюється. Незважаючи на це, Digest Auth рідко використовується в сучасних API — Bearer Token забезпечує кращу гнучкість.


Bearer Token та JWT

Bearer Token (RFC 6750) — сучасний стандарт аутентифікації у REST API. Клієнт отримує токен після аутентифікації і передає його з кожним запитом:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJBbGljZSIsInJvbGVzIjpbInVzZXIiXSwiZXhwIjoxNzQ3NDgwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

JWT: структура та розшифрування

JWT (JSON Web Token, RFC 7519) — найпопулярніший формат токену. Три Base64URL-закодовані частини, розділені крапкою: header.payload.signature.

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

rectangle "JWT Token" #f5f5f5 {
    rectangle "Header\n(Base64URL)" as header #e3f2fd {
        rectangle "{\n  \"alg\": \"HS256\",\n  \"typ\": \"JWT\"\n}" as hj #bbdefb
    }
    rectangle "Payload (Claims)\n(Base64URL)" as payload #e8f5e9 {
        rectangle "{\n  \"sub\": \"42\",\n  \"name\": \"Alice\",\n  \"roles\": [\"user\"],\n  \"exp\": 1747480000,\n  \"iat\": 1747476400\n}" as pj #a5d6a7
    }
    rectangle "Signature\n(Base64URL)" as sig #fce4ec {
        rectangle "HMACSHA256(\n  header + '.' + payload,\n  secret_key\n)" as sj #f48fb1
    }

    header -[hidden]right-> payload
    payload -[hidden]right-> sig
}

note bottom of header
  Алгоритм підпису.
  Можна прочитати без ключа!
end note

note bottom of payload
  Claims — твердження.
  Читається без ключа.
  Не можна підробити підпис.
end note

note bottom of sig
  Перевірка цілісності.
  Лише той, хто має ключ,
  може створити/перевірити.
end note

@enduml

JWT Claims поділяються на три категорії:

ТипПоляПризначення
Registeredsub, iss, aud, exp, nbf, iat, jtiСтандартизовані RFC 7519
Publicname, email, rolesПублічні, реєструються IANA
Privatedepartment, tenantIdВласні claims застосунку
sub  — subject: ідентифікатор суб'єкта (userId)
iss  — issuer: хто видав токен
aud  — audience: для кого токен (перевіряється при валідації)
exp  — expiration time: Unix timestamp закінчення
nbf  — not before: токен недійсний до цього часу
iat  — issued at: час видачі
jti  — JWT ID: унікальний ідентифікатор (для blacklist)
JWT — це не шифрування! Header та Payload можна прочитати без будь-якого ключа (просто Base64URL-декодувати). JWT підписується, але не шифрується (якщо не використовується JWE). Ніколи не зберігайте у JWT секретних даних: паролів, номерів карток, PII понад необхідний мінімум.

JWT алгоритми підпису

АлгоритмТипКлючПеревагаНедолік
HS256SymmetricОдин секретПростий, швидкийВсі сервіси мають один ключ
HS512SymmetricОдин секретДовший хешБільший розмір
RS256AsymmetricPrivate/Public keyПублічна верифікаціяПовільніший, більший токен
ES256Asymmetric (ECDSA)Private/Public keyМенший за RS256Складніша реалізація

HS256 — для монолітних застосунків (один сервер видає і перевіряє). RS256 — для мікросервісів: auth-сервер підписує приватним ключем, інші сервіси перевіряють публічним (без доступу до секрету).

JWT валідація: 7 кроків

1. Перевірити підпис — HMAC/RSA верифікація
2. Перевірити exp (expiration) — токен не протух
3. Перевірити nbf (not before) — токен вже активний
4. Перевірити iss (issuer) — від правильного видавця
5. Перевірити aud (audience) — для цього сервісу
6. Перевірити alg — алгоритм відповідає очікуваному (не "none"!)
7. Опційно: перевірити jti у blacklist (для відкликання)
Атака "alg=none": деякі бібліотеки приймали токени з "alg":"none" без підпису. Завжди явно вказуйте очікуваний алгоритм при валідації і відхиляйте токени з іншими алгоритмами.

Безпечне зберігання токенів у клієнті

СховищеXSSCSRFPersistenceРекомендація
localStorage❌ Вразливий✅ Не вразливийPermanent❌ Не рекомендується
sessionStorage❌ Вразливий✅ Не вразливийTab-scoped⚠ Тільки не-критичні
HttpOnly Cookie✅ Захищений❌ Вразливий (CSRF)ConfigurableРекомендується + SameSite
Memory (JS var)✅ Захищений✅ Не вразливийNone (tab close)✅ Для access tokens

Рекомендована стратегія для SPA:

  • Access token — у пам'яті JavaScript (втрачається при reload)
  • Refresh token — у HttpOnly; Secure; SameSite=Strict cookie
  • При reload сторінки — тихо обмінювати refresh token на новий access token
using System.Net.Http.Headers;
using System.Net.Http.Json;

// ── Крок 1: Отримати JWT-токен ───────────────────────────────────────────────
// dummyjson.com — реальний API з JWT аутентифікацією (справжній eyJ... токен)
using var client = new HttpClient { BaseAddress = new Uri("https://dummyjson.com/") };

// POST /auth/login → справжній JWT access + refresh token
var credentials = new { username = "emilys", password = "emilyspass", expiresInMins = 30 };
HttpResponseMessage authResponse = await client.PostAsJsonAsync("auth/login", credentials);
authResponse.EnsureSuccessStatusCode();

var tokenResult = await authResponse.Content.ReadFromJsonAsync<TokenResponse>();
Console.WriteLine($"Access token:  {tokenResult?.AccessToken[..30]}...");
Console.WriteLine($"Token type:    {tokenResult?.TokenType ?? "Bearer"}");

// ── Крок 2: Використовувати токен ────────────────────────────────────────────
client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", tokenResult?.AccessToken);

// GET /auth/me — захищений endpoint, повертає поточного користувача
HttpResponseMessage profileResponse = await client.GetAsync("auth/me");
profileResponse.EnsureSuccessStatusCode();

var profile = await profileResponse.Content.ReadFromJsonAsync<UserProfile>();
Console.WriteLine($"Hello, {profile?.FirstName} {profile?.LastName}!");
Console.WriteLine($"Email: {profile?.Email}, Role: {profile?.Role}");

// ── Крок 3: Оновити токен через Refresh Token ─────────────────────────────────
if (tokenResult?.RefreshToken is not null)
{
    var refreshBody = new { refreshToken = tokenResult.RefreshToken, expiresInMins = 30 };
    var refreshResp = await client.PostAsJsonAsync("auth/refresh", refreshBody);

    if (refreshResp.IsSuccessStatusCode)
    {
        var newTokens = await refreshResp.Content.ReadFromJsonAsync<TokenResponse>();
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", newTokens?.AccessToken);
        Console.WriteLine("Токен успішно оновлено.");
    }
}

// ── Моделі ───────────────────────────────────────────────────────────────────
record TokenResponse(string AccessToken, string RefreshToken, int ExpiresIn = 1800, string? TokenType = null);
record UserProfile(int Id, string FirstName, string LastName, string Email, string? Role);

OAuth 2.0: делегована авторизація

OAuth 2.0 (RFC 6749) — це не схема аутентифікації, а фреймворк делегованої авторизації. Він відповідає на питання: «Як дозволити стороньому застосунку діяти від імені користувача, не передаючи йому пароль?»

OAuth 2.0 визначає чотири grant type (варіанти отримання токену), кожен для свого сценарію:

Grant TypeСценарійУчасть користувача
Authorization Code + PKCEВебзастосунки, SPA, мобільніТак — логін на auth server
Client CredentialsServer-to-server (машина-машина)Ні
Device AuthorizationTV, CLI без браузераТак — з іншого пристрою
Refresh TokenОновлення access tokenНі

Authorization Code Flow + PKCE

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

actor "Користувач" as user
participant "Застосунок\n(Client)" as app #e3f2fd
participant "Authorization\nServer\n(Google/GitHub)" as auth #fff9c4
participant "Resource Server\n(API)" as api #e8f5e9

user -> app : Натискає "Увійти через GitHub"

note over app
  PKCE: генерує
  code_verifier (random)
  code_challenge = SHA256(verifier)
end note

app -> auth : GET /authorize?\n  client_id=...&\n  redirect_uri=https://app.com/callback&\n  scope=read:user,repo&\n  state=random_csrf_token&\n  response_type=code&\n  code_challenge=BASE64URL(SHA256(verifier))&\n  code_challenge_method=S256

auth -> user : Сторінка логіну GitHub
user -> auth : Вводить логін/пароль\nта підтверджує scopes
auth -> app : Редирект:\nhttps://app.com/callback?code=AUTH_CODE&state=TOKEN

note over app
  Перевірка state!
  Захист від CSRF.
end note

app -> auth : POST /token\n  code=AUTH_CODE\n  client_id=...\n  code_verifier=ORIGINAL_VERIFIER\n  grant_type=authorization_code
auth -> auth : Перевіряє:\n  SHA256(verifier) == code_challenge
auth --> app : {\n  "access_token": "...",\n  "refresh_token": "...",\n  "expires_in": 3600\n}

app -> api : GET /user\nAuthorization: Bearer ACCESS_TOKEN
api --> app : Дані користувача

@enduml

PKCE (Proof Key for Code Exchange, RFC 7636) — захист від перехоплення authorization code. Навіть якщо зловмисник отримає code (через redirect URI), він не зможе обміняти його на токен без code_verifier, який знає лише оригінальний застосунок.

Client Credentials Flow

// Server-to-server автентифікація без участі користувача.
// Замініть URL на ваш OAuth-сервер:
//   Auth0:     https://YOUR-DOMAIN.auth0.com/oauth/token
//   Keycloak:  https://YOUR-KEYCLOAK/realms/REALM/protocol/openid-connect/token
//   Azure AD:  https://login.microsoftonline.com/TENANT-ID/oauth2/v2.0/token
using var client = new HttpClient();

var tokenRequest = new FormUrlEncodedContent(new[]
{
    new KeyValuePair<string, string>("grant_type", "client_credentials"),
    new KeyValuePair<string, string>("client_id", "my-service-id"),
    new KeyValuePair<string, string>("client_secret", "my-service-secret"),
    new KeyValuePair<string, string>("scope", "read:analytics write:reports"),
});

// httpbingo.org/post — відображає тіло запиту (демонстрація формату без реального OAuth-сервера)
// У реальному проєкті: замінити на URL вашого OAuth-сервера
HttpResponseMessage echoResponse = await client.PostAsync(
    "https://httpbingo.org/post",
    tokenRequest
);
echoResponse.EnsureSuccessStatusCode();

Console.WriteLine("Запит відправлено. Формат: application/x-www-form-urlencoded");
Console.WriteLine("Параметри: grant_type=client_credentials, scope=read:analytics");

// Реальний OAuth-сервер поверне:
// {"access_token":"eyJ...","token_type":"Bearer","expires_in":3600,"scope":"read:analytics"}
// var token = await tokenResponse.Content.ReadFromJsonAsync<OAuthToken>();
// client.DefaultRequestHeaders.Authorization =
//     new AuthenticationHeaderValue("Bearer", token?.AccessToken);
// var data = await client.GetFromJsonAsync<object>("https://your-api.com/analytics/summary");

record OAuthToken(
    string AccessToken,
    string TokenType,
    int ExpiresIn,
    string? Scope
);

Device Authorization Flow

Для пристроїв без браузера (Smart TV, CLI-інструменти, IoT):

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

participant "CLI/TV\n(Device)" as device #e3f2fd
participant "Authorization\nServer" as auth #fff9c4
actor "Користувач\n(з телефоном/ПК)" as user

device -> auth : POST /device/authorize\n  client_id=my-cli&\n  scope=repo

auth --> device : {\n  "device_code": "DEVICE_CODE",\n  "user_code": "ABCD-1234",\n  "verification_uri": "https://auth.com/activate",\n  "expires_in": 900,\n  "interval": 5\n}

device -> device : Виводить:\n  Відкрийте https://auth.com/activate\n  Введіть код: ABCD-1234

loop кожні 5 секунд
    device -> auth : POST /token\n  device_code=DEVICE_CODE\n  grant_type=urn:ietf:params:oauth:grant-type:device_code
    auth --> device : {"error": "authorization_pending"}
end

user -> auth : Відкрив сторінку,\nввів ABCD-1234, підтвердив

device -> auth : POST /token (наступний poll)
auth --> device : {\n  "access_token": "...",\n  "refresh_token": "..."\n}

@enduml

Refresh Token Rotation

Стратегія безпеки: кожен refresh видає новий refresh token і інвалідує старий. При виявленні повторного використання старого refresh token — вся сесія блокується (ознака компрометації).

1. Client → POST /token (refresh_token=RT1)
2. Server → access_token=AT2, refresh_token=RT2 (RT1 інвалідовано)

Якщо зловмисник перехопив RT1:
3. Evil → POST /token (refresh_token=RT1)
4. Server: RT1 вже використано! → Блокувати всю сесію користувача

HTTPS та TLS: шифрування транспорту

Навіщо HTTPS

Без шифрування будь-який вузол між клієнтом і сервером (провайдер, публічний Wi-Fi, проксі) може читати, підмінювати та вставляти довільний контент у передані дані.

HTTPS = HTTP over TLS. TLS (Transport Layer Security) забезпечує три властивості:

ВластивістьМеханізмЩо захищає
КонфіденційністьСиметричне шифрування (AES-256-GCM)Ніхто не читає трафік
ЦілісністьMAC (Message Authentication Code)Ніхто не підміняє дані
АвтентичністьX.509 сертифікатиКлієнт спілкується з правильним сервером

X.509 Сертифікат: анатомія

Сертифікат — це підписаний цифровий документ, що прив'язує публічний ключ до ідентифікатора (домену).

Certificate:
  Version:      3
  Serial:       0A:B4:C2:...
  Algorithm:    SHA256withRSA
  Issuer:       C=US, O=Let's Encrypt, CN=R10
  Validity:
    Not Before: 2025-01-01 00:00:00
    Not After:  2025-04-01 00:00:00   ← 90 днів для Let's Encrypt
  Subject:      CN=api.example.com
  Subject Alt Names (SAN):
    DNS: api.example.com
    DNS: *.example.com
  Public Key:   RSA 2048 bits (або EC P-256)
  Extensions:
    Key Usage: Digital Signature, Key Encipherment
    Extended Key Usage: TLS Web Server Authentication
    OCSP URL: http://ocsp.pki.goog/
    CRL URL:  http://crl.pki.goog/
  Signature:    [підпис Issuer приватним ключем]

SAN (Subject Alternative Names) — саме цей розділ перевіряє браузер при порівнянні з доменом. CN (Common Name) — застаріла перевірка, SAN — сучасний стандарт.

Рівні довіри сертифікатів

ТипПеревірка CAВидається заДля чого
DV (Domain Validated)Лише домен~хвилини (ACME)Більшість сайтів
OV (Organization Validated)Домен + організація~дніКорпоративні сайти
EV (Extended Validation)Детальна перевірка юр. особи~тижніБанки, платіжні

Ланцюжок довіри

Браузер не знає сертифікат кожного сайту. Замість цього він довіряє кільком Root Certificate Authorities (CA), чиї сертифікати вбудовані в ОС та браузер. Сертифікат сервера підписаний ланцюжком до одного з цих довірених Root CA.

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

rectangle "Root CA Certificate\n(в ОС браузера)" as root #e8f5e9 {
    rectangle "ISRG Root X1\nСамопідписаний\n(trust anchor)" as rootcert #a5d6a7
}

rectangle "Intermediate CA Certificate\n(від Root CA)" as inter #e3f2fd {
    rectangle "Let's Encrypt R10\nПідписаний Root CA" as intercert #90caf9
}

rectangle "End-Entity Certificate\n(сертифікат сайту)" as ee #fff9c4 {
    rectangle "api.example.com\nПідписаний Intermediate CA" as eecert #ffe082
}

root --> inter : підписує (offline)
inter --> ee : підписує (online, автоматично)

note right of root
  Зберігається в браузері/ОС.
  Root CA підписи важко компрометувати —
  приватний ключ зберігається offline.
end note

note right of ee
  Надсилається клієнту
  під час TLS handshake.
  Браузер перевіряє підпис
  Intermediate CA, потім
  Intermediate ← Root.
end note

@enduml

Revocation (відкликання) — якщо приватний ключ скомпрометовано, сертифікат відкликається:

  • CRL (Certificate Revocation List) — список відкликаних серійних номерів (великий файл, рідко оновлюється)
  • OCSP (Online Certificate Status Protocol) — запит статусу конкретного сертифікату в реальному часі
  • OCSP Stapling — сервер заздалегідь отримує OCSP відповідь і вкладає її у TLS Handshake (клієнту не потрібно робити окремий запит)

TLS 1.3 Handshake крок за кроком

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

participant "Клієнт\n(браузер)" as client #e3f2fd
participant "Сервер" as server #e8f5e9

== RTT 1: ClientHello + KeyShare ==

client -> server : **ClientHello**\n• Версії TLS: [1.3, 1.2]\n• Cipher Suites: [AES-256-GCM-SHA384, ChaCha20-Poly1305-SHA256]\n• Key Share: ephemeral публічний ключ (ECDH)\n• Server Name (SNI): api.example.com\n• Session ticket (для 0-RTT відновлення)

server -> client : **ServerHello**\n• Вибрана версія: TLS 1.3\n• Вибраний шифр: AES-256-GCM-SHA384\n• Key Share: ephemeral публічний ключ

note over client, server
  ECDHE: обидві сторони обчислюють
  спільний секрет:
  shared = ECDH(client_priv, server_pub)
         = ECDH(server_priv, client_pub)
  Усе подальше — зашифровано!
  Perfect Forward Secrecy гарантовано.
end note

server -> client : **{EncryptedExtensions}** [зашифровано]
server -> client : **{Certificate}** — X.509 + ланцюжок + OCSP staple
server -> client : **{CertificateVerify}** — підпис приватним ключем
server -> client : **{Finished}** — MAC всього handshake

client -> client : Перевірка сертифікату:\n1. Підпис Intermediate CA\n2. Intermediate ← Root CA (довірений)\n3. SAN містить api.example.com\n4. Дата дії\n5. OCSP: не відкликаний

client -> server : **{Finished}** — підтвердження

== RTT 2: HTTP Запит (починається одразу після Finished) ==
client -> server : **{GET /api/data HTTP/1.1}** [зашифровано]

@enduml

TLS 1.3 vs TLS 1.2: ключові відмінності

АспектTLS 1.2TLS 1.3
RTT для handshake2 RTT1 RTT
0-RTT відновленняНемаєЄ (ризик replay)
Cipher suitesБагато (включно із слабкими)5 сильних, тільки AEAD
Forward SecrecyОпційна (DHE/ECDHE)Обов'язкова (завжди ECDHE)
RSA key exchangeПідтримуєтьсяВидалено
Шифрування handshakeCertificate в відкритому виглядіCertificate зашифровано

HSTS: HTTP Strict Transport Security

HSTS (RFC 6797) — заголовок відповіді, що наказує браузеру завжди використовувати HTTPS для цього домену протягом зазначеного часу, навіть якщо користувач вводить http://.

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
ДирективаЗначення
max-age=NКешувати N секунд (рекомендовано ≥ 1 рік = 31536000)
includeSubDomainsЗастосовувати до всіх піддоменів
preloadДозволити внесення у HSTS Preload List

HSTS Preload List — список доменів, вбудований у Chrome, Firefox, Safari. Браузер знає про HTTPS до першого з'єднання, унеможливлюючи SSLstrip-атаки. Для внесення — hstspreload.org.

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

actor "Користувач" as user
participant "Браузер\n(з HSTS кешем)" as browser #e3f2fd
participant "Сервер" as server #e8f5e9

== Перший візит ==
user -> browser : http://bank.com/
browser -> server : GET http://bank.com/ (HTTP)
server --> browser : 301 Moved Permanently\nLocation: https://bank.com/\n---\nhttps://bank.com/ → 200 OK\nStrict-Transport-Security: max-age=31536000

browser -> browser : Зберегти HSTS:\nbank.com → HTTPS only\nдо 2027-01-01

== Наступні візити (протягом року) ==
user -> browser : http://bank.com/
browser -> browser : HSTS hit!\nАвтоматично → https://bank.com/
browser -> server : GET **https**://bank.com/ (без HTTP!)

note right of server
  Зловмисник між клієнтом
  і сервером не може
  "зловити" перший HTTP-запит —
  браузер одразу йде по HTTPS
end note

@enduml

Certificate Transparency

CT (RFC 6962) — публічний реєстр всіх виданих TLS-сертифікатів. Кожен CA зобов'язаний записувати видані сертифікати у публічні CT Logs (Merkle tree). Браузери перевіряють наявність SCT (Signed Certificate Timestamp) у сертифікаті.

Навіщо: якщо CA випустив підроблений сертифікат для google.com, це буде публічно видно у CT Logs протягом хвилин. Без CT — підроблений сертифікат міг би існувати роками без виявлення.

HTTPS у .NET: налаштування HttpClient

using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;

// ── Стандартне HTTPS — без додаткового налаштування ──────────────────────────
// HttpClient автоматично перевіряє сертифікат та використовує TLS 1.2/1.3
using var defaultClient = new HttpClient();
var response = await defaultClient.GetAsync("https://httpbingo.org/get");

// ── Явно вказати дозволені версії TLS ────────────────────────────────────────
var tlsHandler = new HttpClientHandler();
tlsHandler.SslProtocols = System.Security.Authentication.SslProtocols.Tls12
                        | System.Security.Authentication.SslProtocols.Tls13;
using var tlsClient = new HttpClient(tlsHandler);

// ── Кастомна перевірка сертифікату ───────────────────────────────────────────
// ⚠ ТІЛЬКИ ДЛЯ РОЗРОБКИ — ніколи не відключайте перевірку у production!
var devHandler = new HttpClientHandler
{
    ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
    {
        if (errors == SslPolicyErrors.None) return true;
        // Приймаємо self-signed тільки для localhost
        return message.RequestUri?.Host is "localhost" or "127.0.0.1";
    }
};
using var devClient = new HttpClient(devHandler);

// ── Клієнтський сертифікат (mTLS — двостороння автентифікація) ────────────────
var mtlsHandler = new HttpClientHandler();
var clientCert = X509Certificate2.CreateFromPemFile("client.crt", "client.key");
mtlsHandler.ClientCertificates.Add(clientCert);
using var mtlsClient = new HttpClient(mtlsHandler);

// ── Отримати інформацію про сертифікат сервера ─────────────────────────────────
var infoHandler = new HttpClientHandler
{
    ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
    {
        if (cert is not null)
        {
            Console.WriteLine($"Subject:  {cert.Subject}");
            Console.WriteLine($"Issuer:   {cert.Issuer}");
            Console.WriteLine($"Valid to: {cert.GetExpirationDateString()}");
            Console.WriteLine($"Thumbprint: {cert.GetCertHashString()}");
        }
        return errors == SslPolicyErrors.None;
    }
};
using var infoClient = new HttpClient(infoHandler);
await infoClient.GetAsync("https://httpbingo.org/");

HTTP Security Headers

Окрім TLS, сервери повинні надсилати набір security response headers, що захищають клієнтів від різних атак. HttpClient ці заголовки не додає — їх встановлює сервер. Але розробнику важливо розуміти, що вони означають.

Ключові заголовки безпеки

Читання security headers у C#

using System.Net.Http;

using var client = new HttpClient();
// mozilla.org — сайт з повним набором security headers (HSTS, CSP, X-Frame-Options тощо)
HttpResponseMessage response = await client.GetAsync("https://www.mozilla.org/");

// Перевірити наявність security headers
var securityHeaders = new[]
{
    "Strict-Transport-Security",
    "Content-Security-Policy",
    "X-Frame-Options",
    "X-Content-Type-Options",
    "Referrer-Policy",
    "Permissions-Policy"
};

Console.WriteLine("Security Headers Audit:");
foreach (string header in securityHeaders)
{
    bool present = response.Headers.Contains(header)
                || response.Content.Headers.Contains(header);

    string status = present ? "✅" : "❌";
    string value = present
        ? response.Headers.TryGetValues(header, out var vals) ? string.Join(", ", vals) : "(in content headers)"
        : "ВІДСУТНІЙ";

    Console.WriteLine($"{status} {header}: {value}");
}

HTTP-кешування

Навіщо кешування

HTTP-кешування — один з найпотужніших механізмів оптимізації продуктивності. Правильно налаштоване кешування дозволяє зменшити навантаження на сервер у рази, прискорити відповідь для користувача та заощадити трафік.

Ієрархія кешів

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

actor "Браузер" as browser
rectangle "Browser Cache\n(приватний)" as bcache #e3f2fd
rectangle "CDN / Proxy Cache\n(спільний)" as cdn #e8f5e9
participant "Origin Server" as origin #fff9c4

browser -> bcache : GET /logo.png
bcache -> browser : 200 OK (з кешу!)\nHIT — 0ms

browser -> cdn : GET /api/products
cdn -> browser : 200 OK (з кешу CDN)\nHIT — 5ms

browser -> origin : GET /api/user/42
origin -> browser : 200 OK (свіжі дані)\nMISS — 150ms

note right of bcache
  Cache-Control: private
  Лише цей браузер
end note

note right of cdn
  Cache-Control: public
  Всі клієнти CDN
end note

note right of origin
  Cache-Control: no-store
  Або персональні дані
end note

@enduml

Cache-Control: директиви відповіді та запиту

Cache-Control використовується як у відповідях (сервер → кеш → клієнт), так і у запитах (клієнт → кеш).

Директиви відповіді

ДирективаЗначення
publicМожна кешувати у будь-якому кеші (CDN, проксі, браузер)
privateТільки у приватному кеші (браузер конкретного користувача)
no-cacheПеревіряти актуальність перед використанням (conditional GET)
no-storeВзагалі не зберігати у кеші (паролі, банківські дані)
max-age=NКешувати N секунд без перевірки
s-maxage=NДля CDN: кешувати N секунд (ігнорує max-age)
must-revalidateПісля закінчення max-age — обов'язково перевірити на сервері
immutableРесурс ніколи не зміниться (статичні файли з хешем у URL)
stale-while-revalidate=NПоки оновлює у фоні — видавати застарілий кеш ще N секунд
stale-if-error=NПри помилці сервера — видавати застарілий кеш ще N секунд

Директиви запиту (клієнт управляє кешем)

ДирективаЗначення
no-cacheНе використовувати кешовану відповідь без перевірки
no-storeНе зберігати цей запит/відповідь
max-age=0Отримати свіжу відповідь (аналог no-cache)
max-stale=NПрийняти застарілу відповідь, якщо вона не старша N секунд
only-if-cachedПовернути тільки кешовану відповідь, не йти на сервер

stale-while-revalidate: фоновий refresh

Cache-Control: max-age=60, stale-while-revalidate=30
  • Перші 60 сек: свіжий кеш, відповідає миттєво
  • 60–90 сек: кеш "протух", але повертається одразу + у фоні оновлюється
  • Після 90 сек: чекати відповіді сервера

Ідеальний баланс між актуальністю та latency для API з не-критичними даними.

Cache Busting для статичних ресурсів

<!-- BAD: старий URL, може кешуватись нескінченно -->
<script src="/app.js"></script>

<!-- GOOD: хеш вмісту у URL → при зміні файлу URL змінюється -->
<script src="/app.a1b2c3d4.js"></script>
Cache-Control: public, max-age=31536000, immutable

Хеш у URL + immutable = агресивне кешування на рік без перевірок. CDN кешує максимально. При деплої новий хеш = новий URL = примусове завантаження.

Conditional GET: ETag та Last-Modified

Механізм, що дозволяє клієнту перевірити актуальність кешованого ресурсу без його повного завантаження.

ETag (Entity Tag)

ETag — довільний ідентифікатор версії ресурсу (хеш вмісту, версія, timestamp). Сервер генерує; клієнт надсилає назад через If-None-Match.

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

participant "Клієнт" as client #e3f2fd
participant "Сервер" as server #e8f5e9

== Перший запит ==
client -> server : GET /api/products
server --> client : 200 OK\nETag: "v5-abc123"\nCache-Control: max-age=60\n\n[список продуктів — 50KB]

note over client : Кешує: resource + ETag "v5-abc123"

== Через 61 секунду ==
client -> server : GET /api/products\nIf-None-Match: "v5-abc123"

alt Ресурс НЕ змінився
    server --> client : **304 Not Modified**\nETag: "v5-abc123"\n\n(тіло відсутнє — 0 байт!)
    note over client : Використовує кеш — 50KB зекономлено
else Ресурс змінився
    server --> client : 200 OK\nETag: "v6-xyz789"\n\n[новий список — 52KB]
end

@enduml

Last-Modified та If-Modified-Since

Альтернатива ETag на основі часу останньої модифікації:

GET /api/products HTTP/1.1


HTTP/1.1 200 OK
Last-Modified: Thu, 22 May 2025 10:00:00 GMT
Cache-Control: max-age=60
Content-Length: 51200

[50KB body]
GET /api/products HTTP/1.1
If-Modified-Since: Thu, 22 May 2025 10:00:00 GMT


HTTP/1.1 304 Not Modified
Last-Modified: Thu, 22 May 2025 10:00:00 GMT

(без тіла!)

ETag vs Last-Modified:

  • ETag точніший (змінюється тільки при зміні вмісту, не лише часу)
  • ETag підтримує паралельні версії (W/"weak" для приблизного порівняння)
  • Last-Modified простіший, але секундна точність може не вистачати
  • Рекомендація: надсилати обидва заголовки
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;

using var client = new HttpClient { BaseAddress = new Uri("https://httpbingo.org/") };

string? cachedETag = null;
DateTimeOffset? cachedLastModified = null;
string? cachedContent = null;

async Task<string?> GetProductsAsync()
{
    // httpbingo.org/etag/{etag} — повертає ETag і відповідає 304 при If-None-Match
    var request = new HttpRequestMessage(HttpMethod.Get, "etag/v5-abc123");

    // Conditional GET: ETag
    if (cachedETag is not null)
        request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue($"\"{cachedETag}\""));

    // Conditional GET: Last-Modified
    if (cachedLastModified.HasValue)
        request.Headers.IfModifiedSince = cachedLastModified.Value;

    HttpResponseMessage response = await client.SendAsync(request);

    if (response.StatusCode == HttpStatusCode.NotModified)
    {
        Console.WriteLine("📦 304 Not Modified — 0 байт завантажено");
        return cachedContent;
    }

    response.EnsureSuccessStatusCode();

    // Зберігаємо валідатори для наступного запиту
    if (response.Headers.ETag is not null)
        cachedETag = response.Headers.ETag.Tag.Trim('"');

    if (response.Content.Headers.LastModified.HasValue)
        cachedLastModified = response.Content.Headers.LastModified;

    cachedContent = await response.Content.ReadAsStringAsync();
    Console.WriteLine($"🔄 200 OK — {cachedContent.Length} байт завантажено");
    return cachedContent;
}

var first = await GetProductsAsync();   // 200 OK — 50000 байт
var second = await GetProductsAsync();  // 304 Not Modified — 0 байт

Content Negotiation та Compression

Content Negotiation

Механізм, що дозволяє клієнту та серверу погодитись про формат представлення ресурсу через заголовки сімейства Accept-*.

Q-values: вагові коефіцієнти

Кожна опція у Accept заголовку може мати q-value від 0 до 1.0 (за замовчуванням 1.0). Сервер обирає формат з найвищим q-value серед доступних.

Accept: application/json;q=1.0, application/xml;q=0.8, text/plain;q=0.5, */*;q=0.1

Розшифрування: «Найбільш бажаний — JSON (1.0), потім XML (0.8), потім text (0.5), будь-що інше (0.1)».

Повний набір Accept-* заголовків

ЗаголовокЩо узгоджуєПриклад
AcceptФормат тіла відповідіapplication/json, application/xml
Accept-LanguageМова відповідіuk;q=1.0, en;q=0.8
Accept-EncodingАлгоритм стисненняbr, gzip, deflate
Accept-CharsetКодування (застарілий)utf-8, iso-8859-1
GET /api/users/42 HTTP/1.1
Accept: application/json;q=1.0, application/xml;q=0.8, text/plain;q=0.5
Accept-Language: uk;q=1.0, en;q=0.8
Accept-Encoding: br, gzip, deflate

Відповідь сервера:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: uk
Content-Encoding: br
Vary: Accept, Accept-Language, Accept-Encoding

406 Not Acceptable

Якщо сервер не може задовольнити жоден з Accept форматів:

HTTP/1.1 406 Not Acceptable
Content-Type: application/problem+json

{
  "title": "Not Acceptable",
  "detail": "Сервер підтримує тільки application/json і application/xml"
}
Заголовок Vary: Критично важливий для кешів. Він вказує, від яких заголовків запиту залежить представлення ресурсу. Vary: Accept означає, що кеш повинен зберігати окремі копії для JSON і XML клієнтів. Без Vary кеш може повернути XML клієнту, що просив JSON.
using System.Net.Http;
using System.Net.Http.Headers;

using var client = new HttpClient { BaseAddress = new Uri("https://httpbingo.org/") };

// Явно вказати бажані формати з пріоритетами
// httpbingo.org/get — відображає заголовки запиту (для перевірки content negotiation)
var request = new HttpRequestMessage(HttpMethod.Get, "get");

request.Headers.Accept.Clear();
request.Headers.Accept.Add(
    new MediaTypeWithQualityHeaderValue("application/json") { Quality = 1.0 }
);
request.Headers.Accept.Add(
    new MediaTypeWithQualityHeaderValue("application/xml") { Quality = 0.8 }
);

// Мова
request.Headers.AcceptLanguage.Add(
    new StringWithQualityHeaderValue("uk") { Quality = 1.0 }
);
request.Headers.AcceptLanguage.Add(
    new StringWithQualityHeaderValue("en") { Quality = 0.8 }
);

HttpResponseMessage response = await client.SendAsync(request);

Console.WriteLine($"Content-Type: {response.Content.Headers.ContentType}");
Console.WriteLine($"Content-Language: {response.Headers.GetValues("Content-Language").FirstOrDefault()}");
Console.WriteLine($"Content-Encoding: {response.Content.Headers.ContentEncoding.FirstOrDefault()}");

Стиснення відповіді

using System.IO.Compression;
using System.Net;
using System.Net.Http;

// HttpClientHandler підтримує автоматичну деcompression
var handler = new HttpClientHandler
{
    AutomaticDecompression = DecompressionMethods.GZip
                           | DecompressionMethods.Deflate
                           | DecompressionMethods.Brotli
};

// Автоматично додає: Accept-Encoding: gzip, deflate, br
// Автоматично розпаковує відповідь — прозоро для коду
using var client = new HttpClient(handler);

// httpbingo.org/gzip — завжди повертає gzip-стиснений JSON
HttpResponseMessage response = await client.GetAsync("https://httpbingo.org/gzip");

Console.WriteLine($"Content-Length: {response.Content.Headers.ContentLength}"); // може бути null при chunked
string content = await response.Content.ReadAsStringAsync(); // вже розпаковано
Console.WriteLine($"Розпакований розмір: {content.Length} символів");

Порівняння алгоритмів стиснення:

АлгоритмСтупінь стисненняШвидкість розпакуванняПідтримка браузерами
GzipСереднійВисока100%
DeflateСереднійВисока100% (але проблеми)
Brotli (br)Найкращий (+15-25% vs gzip)Висока95%+
Коли не стискати: зображення (JPEG/PNG/WebP вже стиснені), відео, зашифровані дані. Стиснення таких типів збільшує розмір і витрачає CPU.

CORS: Cross-Origin Resource Sharing

Проблема: Same-Origin Policy

Браузери реалізують Same-Origin Policy (SOP) — правило безпеки, що забороняє JavaScript одного джерела робити запити до іншого джерела без явного дозволу сервера.

Origin = схема + хост + порт. Усі три компоненти мають співпадати.

URL запитуOrigin сторінкиРезультатПричина
https://api.example.com/datahttps://app.example.com❌ Cross-originРізні хости
https://api.example.com/datahttp://api.example.com❌ Cross-originРізна схема
https://api.example.com:8080/datahttps://api.example.com❌ Cross-originРізний порт
https://api.example.com/usershttps://api.example.com✅ Same-originВсе співпадає

Навіщо SOP? Без нього будь-який сайт міг би виконати fetch("https://bank.com/account") з cookies жертви і прочитати баланс. SOP не захищає від CSRF (браузер все одно надсилає запит), але захищає від читання відповіді.

Простий vs Preflight запит

Simple Request — не вимагає preflight, якщо:

  • Метод: GET, HEAD, POST
  • Content-Type: text/plain, application/x-www-form-urlencoded, multipart/form-data
  • Немає кастомних заголовків

Preflighted Request — вимагає попереднього OPTIONS запиту, якщо:

  • Метод: PUT, DELETE, PATCH
  • Content-Type: application/json
  • Будь-який кастомний заголовок (Authorization, X-Request-Id)
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff

actor "JS\napp.example.com" as js
participant "Браузер" as browser #e3f2fd
participant "API\napi.example.com" as api #e8f5e9

== Preflighted Request ==
js -> browser : fetch("/data", {\n  method: "PUT",\n  headers: { Authorization: "Bearer ..." }\n})

browser -> api : **OPTIONS** /data HTTP/1.1\nOrigin: https://app.example.com\nAccess-Control-Request-Method: PUT\nAccess-Control-Request-Headers: Authorization, Content-Type

api --> browser : 204 No Content\nAccess-Control-Allow-Origin: https://app.example.com\nAccess-Control-Allow-Methods: GET, PUT, DELETE, PATCH\nAccess-Control-Allow-Headers: Authorization, Content-Type\nAccess-Control-Max-Age: 86400\nAccess-Control-Allow-Credentials: true

note over browser
  Preflight кешується 86400 секунд!
  Наступного разу — без OPTIONS.
end note

browser -> api : **PUT** /data HTTP/1.1\nOrigin: https://app.example.com\nAuthorization: Bearer eyJ...\nContent-Type: application/json

api --> browser : 200 OK\nAccess-Control-Allow-Origin: https://app.example.com\nAccess-Control-Expose-Headers: X-Total-Count

browser --> js : Відповідь доступна JS

@enduml

Credentialed запити: cookies та CORS

За замовчуванням cross-origin запити не включають cookies. Для цього потрібен explicit opt-in з обох сторін:

Клієнт (JavaScript):

fetch('https://api.example.com/me', {
    credentials: 'include', // відправити cookies та Authorization
})

Сервер (відповідь):

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
При Access-Control-Allow-Credentials: trueзаборонено використовувати Access-Control-Allow-Origin: *. Обов'язково вказувати конкретний origin. Інакше браузер заблокує відповідь навіть при отриманні *.

CORS-заголовки відповіді

Access-Control-Allow-Origin
origin | _
Дозволені origin. _— будь-який (несумісно з credentials). Для конкретних:https://app.example.com. Для кількох доменів — сервер динамічно перевіряє Origin запиту і відповідає конкретним.
Access-Control-Allow-Methods
HTTP методи
Дозволені методи: GET, POST, PUT, DELETE, PATCH, OPTIONS.
Access-Control-Allow-Headers
header names
Дозволені заголовки запиту: Authorization, Content-Type, X-Request-Id.
Access-Control-Allow-Credentials
boolean
true — дозволити cookies та Authorization. При trueAllow-Originне може бути *.
Access-Control-Max-Age
секунди
Кешування preflight відповіді: 86400 = 24 год. Зменшує кількість OPTIONS запитів.
Access-Control-Expose-Headers
header names
Заголовки відповіді, доступні JavaScript (за замовчуванням лише CORS-safe-listed). X-Total-Count, X-Request-Id, Link.

Типові помилки CORS та їх усунення

CORS у .NET HttpClient

CORS — захист браузера, а не API! Сервер завжди отримує запит незалежно від CORS. HttpClient у .NET — не браузер і не дотримується CORS. Обмеження CORS діють тільки на JavaScript у браузері.
using System.Net.Http;

// HttpClient не має ніяких CORS обмежень — це суто браузерна функція
using var client = new HttpClient();

// httpbingo.org підтримує CORS — повертає Access-Control-Allow-Origin
// Емуляція cross-origin запиту з Origin заголовком
var request = new HttpRequestMessage(HttpMethod.Get, "https://httpbingo.org/get");
request.Headers.Add("Origin", "https://app.other-domain.com");

HttpResponseMessage response = await client.SendAsync(request);

// Переглянути CORS заголовки відповіді
if (response.Headers.TryGetValues("Access-Control-Allow-Origin", out var origins))
    Console.WriteLine($"Allowed Origin: {string.Join(", ", origins)}");

if (response.Headers.TryGetValues("Access-Control-Allow-Methods", out var methods))
    Console.WriteLine($"Allowed Methods: {string.Join(", ", methods)}");

// Перевірка preflight вручну
var preflight = new HttpRequestMessage(HttpMethod.Options, "https://httpbingo.org/get");
preflight.Headers.Add("Origin", "https://app.other-domain.com");
preflight.Headers.Add("Access-Control-Request-Method", "PUT");
preflight.Headers.Add("Access-Control-Request-Headers", "Authorization, Content-Type");

HttpResponseMessage preflightResp = await client.SendAsync(preflight);
Console.WriteLine($"Preflight: {preflightResp.StatusCode}");
if (preflightResp.Headers.TryGetValues("Access-Control-Max-Age", out var maxAge))
    Console.WriteLine($"Preflight кешується: {string.Join(", ", maxAge)} секунд");

Redirects: деталі та підводні камені

Типи редиректів та поведінка при POST

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

[*] --> Request : HTTP запит

state "Отримано 3xx" as r3xx {
    state "301 Moved Permanently" as s301 : POST може → GET\n(старі клієнти)\nКешується назавжди
    state "302 Found" as s302 : POST може → GET\nНЕ кешується
    state "303 See Other" as s303 : Завжди GET\nПісля POST → редирект на результат
    state "307 Temporary Redirect" as s307 : Метод зберігається!\nPOST залишається POST\nНЕ кешується
    state "308 Permanent Redirect" as s308 : Метод зберігається!\nPOST залишається POST\nКешується назавжди
}

Request --> r3xx
r3xx --> [*] : Новий запит на Location

@enduml
КодПостійнийЗберігає методТипове застосування
301ТакНі (POST→GET у старих)Зміна домену, URL реструктуризація
302НіНі (POST→GET у старих)Тимчасова заміна (не рекомендується)
303НіЗавжди GETPOST/Redirect/GET паттерн
307НіТакТимчасовий редирект із збереженням методу
308ТакТакПостійний редирект із збереженням методу

Post/Redirect/Get (PRG) pattern: після успішного POST відповідати 303 See Other + Location: /success. Браузер перейде GET на сторінку успіху. При натисканні «Назад/Оновити» браузер не повторить POST.

Open Redirect вразливість

Open Redirect — вразливість, коли сервер перенаправляє на URL, що приходить від клієнта без валідації:
https://bank.com/redirect?to=https://evil.com/phishing
→ 302 Location: https://evil.com/phishing
Зловмисник надсилає жертві посилання на bank.com (довірений домен), яке перенаправляє на фішинговий сайт. Жертва бачить у рядку банківський домен і не підозрює.Захист: Ніколи не використовувати параметри запиту як URL для редиректу. Якщо redirect потрібен — перевіряти, що target URL є allowlisted відносним шляхом.
using System.Net;
using System.Net.Http;

// HttpClient за замовчуванням автоматично слідує редиректам (до 50)
using var autoClient = new HttpClient();
// AllowAutoRedirect = true за замовчуванням

// Вимкнути автоматичні редиректи:
var handler = new HttpClientHandler { AllowAutoRedirect = false };
using var manualClient = new HttpClient(handler);

HttpResponseMessage response = await manualClient.GetAsync("https://httpbingo.org/absolute-redirect/2");

while (response.StatusCode is HttpStatusCode.MovedPermanently
                           or HttpStatusCode.Found
                           or HttpStatusCode.SeeOther
                           or HttpStatusCode.TemporaryRedirect
                           or HttpStatusCode.PermanentRedirect)
{
    Uri? newLocation = response.Headers.Location;
    if (newLocation is null) break;

    // Якщо URL відносний — перетворимо на абсолютний
    if (!newLocation.IsAbsoluteUri)
        newLocation = new Uri(new Uri("https://httpbingo.org"), newLocation);

    // Валідація: чи це очікуваний домен?
    if (newLocation.Host != "httpbingo.org")
    {
        Console.WriteLine($"⚠ Підозрілий редирект на: {newLocation}");
        break;
    }

    Console.WriteLine($"→ Редирект {(int)response.StatusCode}: {newLocation}");
    var method = response.StatusCode == HttpStatusCode.SeeOther
        ? HttpMethod.Get // 303 завжди GET
        : HttpMethod.Get; // спрощено; реально — зберегти оригінальний

    response = await manualClient.GetAsync(newLocation);
}

Console.WriteLine($"Фінальна відповідь: {response.StatusCode}");

Практичний проєкт від A до Z: Auth-aware HTTP Client

Побудуємо повноцінний HTTP-клієнт з підтримкою JWT-аутентифікації, автоматичного оновлення токену та повторних запитів.

Архітектура

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

package "AuthHttpClient" #f5f5f5 {

    class "TokenStorage" as ts #e8f5e9 {
        - _accessToken: string?
        - _refreshToken: string?
        - _expiresAt: DateTime
        ---
        + IsExpired: bool
        + AccessToken: string?
        + RefreshToken: string?
        + Store(access, refresh, expiresIn)
        + Clear()
    }

    class "AuthHandler\n: DelegatingHandler" as ah #e3f2fd {
        - _storage: TokenStorage
        - _factory: IHttpClientFactory
        ---
        # SendAsync(request, ct)
        - RefreshTokenAsync(ct): bool
        - CloneRequestAsync(request)
    }

    class "AuthApiClient" as aac #fff9c4 {
        - _http: HttpClient
        ---
        + LoginAsync(creds): Task<bool>
        + GetProfileAsync(): Task<UserProfile?>
        + GetOrdersAsync(): Task<Order[]>
        + LogoutAsync()
    }

    aac --> ah : pipeline
    ah --> ts : читає/зберігає токени
}

note right of ah
  Алгоритм:
  1. Якщо токен протух → refresh
  2. Прикріпити Bearer token
  3. Відправити запит
  4. Якщо 401 → refresh + retry
  5. Якщо знову 401 → logout
end note

@enduml

Реалізація TokenStorage

// Storage/TokenStorage.cs
namespace AuthClient.Storage;

public sealed class TokenStorage
{
    private string? _accessToken;
    private string? _refreshToken;
    private DateTime _expiresAt;

    // 30-секундний буфер щоб не надсилати майже-протухлий токен
    public bool IsExpired =>
        _accessToken is null || DateTime.UtcNow >= _expiresAt.AddSeconds(-30);

    public string? AccessToken => _accessToken;
    public string? RefreshToken => _refreshToken;

    public void Store(string access, string refresh, int expiresInSeconds)
    {
        _accessToken = access;
        _refreshToken = refresh;
        _expiresAt = DateTime.UtcNow.AddSeconds(expiresInSeconds);
    }

    public void Clear()
    {
        _accessToken = null;
        _refreshToken = null;
        _expiresAt = default;
    }
}

Реалізація AuthHandler

// Handlers/AuthHandler.cs
namespace AuthClient.Handlers;

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using AuthClient.Storage;

public sealed class AuthHandler(TokenStorage storage, IHttpClientFactory factory)
    : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken ct)
    {
        // Пропускаємо login/refresh — вони не потребують Bearer токену
        string? path = request.RequestUri?.AbsolutePath;
        bool isPublicEndpoint = path?.EndsWith("/login") == true
                             || path?.EndsWith("/refresh") == true;
        if (isPublicEndpoint)
            return await base.SendAsync(request, ct);

        // Токен протух — оновлюємо ПЕРЕД відправкою запиту
        if (storage.IsExpired && storage.RefreshToken is not null)
            await RefreshTokenAsync(ct);

        // Прикріплюємо актуальний токен
        if (storage.AccessToken is not null)
            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", storage.AccessToken);

        HttpResponseMessage response = await base.SendAsync(request, ct);

        // 401 — сервер відхилив токен → спробувати refresh і повторити
        if (response.StatusCode == HttpStatusCode.Unauthorized
            && storage.RefreshToken is not null)
        {
            bool refreshed = await RefreshTokenAsync(ct);
            if (refreshed)
            {
                var retryRequest = await CloneRequestAsync(request);
                retryRequest.Headers.Authorization =
                    new AuthenticationHeaderValue("Bearer", storage.AccessToken!);

                response = await base.SendAsync(retryRequest, ct);
            }
        }

        // Якщо знову 401 — всі токени недійсні
        if (response.StatusCode == HttpStatusCode.Unauthorized)
            storage.Clear();

        return response;
    }

    private async Task<bool> RefreshTokenAsync(CancellationToken ct)
    {
        using var refreshClient = factory.CreateClient("Auth");

        var body = new { refreshToken = storage.RefreshToken, expiresInMins = 30 };
        HttpResponseMessage response = await refreshClient.PostAsJsonAsync("auth/refresh", body, ct);

        if (!response.IsSuccessStatusCode)
        {
            storage.Clear();
            return false;
        }

        var result = await response.Content.ReadFromJsonAsync<TokenResponse>(ct);
        if (result is null) return false;

        // ExpiresIn не повертається dummyjson, використовуємо стандарт 1800с
        storage.Store(result.AccessToken, result.RefreshToken, result.ExpiresIn > 0 ? result.ExpiresIn : 1800);
        return true;
    }

    private static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage original)
    {
        var clone = new HttpRequestMessage(original.Method, original.RequestUri);
        clone.Version = original.Version;

        foreach (var header in original.Headers)
            clone.Headers.TryAddWithoutValidation(header.Key, header.Value);

        if (original.Content is not null)
        {
            byte[] content = await original.Content.ReadAsByteArrayAsync();
            clone.Content = new ByteArrayContent(content);

            foreach (var header in original.Content.Headers)
                clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
        }

        return clone;
    }
}

record TokenResponse(string AccessToken, string RefreshToken, int ExpiresIn = 1800);

Program.cs — збірка та демонстрація

// Program.cs
using Microsoft.Extensions.DependencyInjection;
using AuthClient.Handlers;
using AuthClient.Storage;
using System.Net.Http.Json;

var services = new ServiceCollection();
var tokenStorage = new TokenStorage();

services.AddSingleton(tokenStorage);
services.AddTransient<AuthHandler>();

// "Auth" клієнт — для auth-запитів (без AuthHandler!)
services.AddHttpClient("Auth", c =>
    c.BaseAddress = new Uri("https://dummyjson.com/"));

// "Api" клієнт — з AuthHandler у pipeline
services.AddHttpClient("Api", c =>
    c.BaseAddress = new Uri("https://dummyjson.com/"))
    .AddHttpMessageHandler<AuthHandler>();

var sp = services.BuildServiceProvider();
var factory = sp.GetRequiredService<IHttpClientFactory>();

// ── Логін ────────────────────────────────────────────────────────────────────
var authClient = factory.CreateClient("Auth");
// dummyjson.com: POST /auth/login {username, password}
var loginBody = new { username = "emilys", password = "emilyspass", expiresInMins = 30 };
var loginResp = await authClient.PostAsJsonAsync("auth/login", loginBody);

if (loginResp.IsSuccessStatusCode)
{
    var tokens = await loginResp.Content.ReadFromJsonAsync<TokenResponse>();
    int expiresIn = tokens!.ExpiresIn > 0 ? tokens.ExpiresIn : 1800;
    tokenStorage.Store(tokens!.AccessToken, tokens.RefreshToken, expiresIn);
    Console.WriteLine("✅ Аутентифікація успішна");
}

// ── Захищені запити — Bearer token додається автоматично ──────────────────────
var apiClient = factory.CreateClient("Api");

// GET /auth/me — профіль поточного користувача (dummyjson перевіряє Bearer)
var profile = await apiClient.GetAsync("auth/me");
Console.WriteLine($"Profile: {profile.StatusCode}");

// GET /auth/carts — кошики поточного користувача (dummyjson protected endpoint)
var carts = await apiClient.GetAsync("auth/carts");
Console.WriteLine($"Carts: {carts.StatusCode}");

record TokenResponse(string AccessToken, string RefreshToken, int ExpiresIn = 1800);
dotnet run — AuthClient
✅ Аутентифікація успішна
→ GET /auth/me (Bearer eyJhbGc...)
← 200 OK (87ms)
Profile: OK
→ GET /auth/carts (Bearer eyJhbGc...)
← 200 OK (112ms)
Carts: OK

Практика та закріплення

HTTP Advanced охоплює механізми, що є основою реальних вебзастосунків.

Рівень 1. Базове розуміння

  1. Поясніть різницю між session cookie та persistent cookie. Коли слід використовувати кожен тип? Наведіть практичні сценарії.
  2. Що таке CSRF-атака і як атрибут SameSite=Lax захищає від неї? Чому SameSite=None небезпечний без Secure?
  3. Чому __Host- prefix безпечніший за звичайний session cookie? Яку атаку він запобігає?
  4. Поясніть різницю між 401 Unauthorized та 403 Forbidden у контексті аутентифікації та авторизації. Наведіть сценарій для кожного.
  5. Чому JWT не слід зберігати у localStorage? Де правильно зберігати access token та refresh token в SPA?
  6. Які три властивості забезпечує TLS? Чи може TLS захистити від підміни даних після того, як вони дійшли до сервера?
  7. Поясніть, чому HSTS захищає від SSLstrip навіть якщо сервер вже підтримує HTTPS.
  8. Що таке Certificate Transparency і чому вона важлива для безпеки?

Рівень 2. Практична реалізація

  1. Реалізуйте CookieAuthHandler : DelegatingHandler, що:
    • При першому запиті перевіряє наявність збереженого __Host-session cookie
    • Якщо відсутній — виконує логін і зберігає session ID
    • Додає Cookie: __Host-session=... до кожного наступного запиту
    • При отриманні 401 очищає cookie і повторює логін один раз
  2. Напишіть консольний застосунок, що демонструє conditional GET:
    • Перший GET /api/products → зберегти ETag та Last-Modified
    • Другий запит з If-None-Match та If-Modified-Since
    • Порівняти кількість завантажених байт між двома запитами
  3. Реалізуйте CORS-аудитор як консольну утиліту:
    • Приймає origin URL та цільовий API URL
    • Надсилає OPTIONS preflight
    • Виводить дозволені методи, заголовки, credentials, max-age
    • Перевіряє наявність security headers (HSTS, CSP, X-Content-Type-Options)
  4. Реалізуйте SecurityHeadersAuditor, що аналізує сайт на наявність:
    • HSTS з preload
    • CSP з директивою script-src 'self'
    • X-Content-Type-Options: nosniff
    • Referrer-Policy
    • Виводить оцінку безпеки від A до F

Рівень 3. Архітектурне мислення

  1. Спроектуйте TokenManager з підтримкою:
    • Concurrent refresh protection: якщо кілька потоків одночасно виявили протухлий токен — refresh відбувається лише один раз (SemaphoreSlim)
    • Exponential backoff при невдалих спробах refresh (1s, 2s, 4s, max 30s)
    • Secure storage: токени не зберігаються у plaintext
  2. Проаналізуйте безпеку підходів до зберігання JWT:
    • localStorage — які атаки можливі?
    • sessionStorage — чим відрізняється від localStorage?
    • HttpOnly Cookie — які атаки можливі? Як поєднати з CSRF-захистом?
    • Memory-only — як відновити після reload?
  3. Спроектуйте систему відкликання JWT токенів для API з:
    • Short-lived access tokens (15 хв) + long-lived refresh tokens (30 днів)
    • Refresh token rotation при кожному оновленні
    • Виявлення компрометованих refresh tokens (reuse detection)
    • Відкликання всіх сесій користувача ("вийти з усіх пристроїв")
При роботі з аутентифікацією завжди мислите моделлю загроз: хто атакує, що він може зробити, і як ваш механізм захищає. Ідеальної схеми немає — є компроміси між безпекою, зручністю і складністю реалізації.

Контрольні питання

  1. В чому принципова різниця між HTTP аутентифікацією (authentication) та авторизацією (authorization)?
  2. Чому сервер відповідає Set-Cookie: __Host-session=; Max-Age=0; Secure при logout, а не просто закриває з'єднання?
  3. Що означає атрибут HttpOnly у cookie? Від якої конкретної атаки він захищає і чому він не захищає від CSRF?
  4. Поясніть, чому Access-Control-Allow-Origin: * несумісний з Access-Control-Allow-Credentials: true.
  5. В чому різниця між 301 та 308 редиректами при POST-запиті?
  6. Що таке HSTS і навіщо він потрібен, якщо сервер вже підтримує HTTPS? Що відбудеться при першому візиті до сайту без HSTS Preload?
  7. Чому JWT access tokens зазвичай мають короткий термін (15–60 хвилин)?
  8. Що таке PKCE і від якої атаки він захищає в Authorization Code Flow?
  9. Яку роль відіграє Vary: Accept-Encoding у HTTP-кешуванні CDN? Що відбудеться без нього?
  10. Поясніть різницю між HS256 (symmetric) та RS256 (asymmetric) підписом JWT. Коли слід використовувати кожен?
  11. Що таке Refresh Token Rotation і як він допомагає виявляти компрометовані refresh tokens?
  12. Чому stale-while-revalidate краще підходить для API, ніж просто max-age=N без можливості фонового оновлення?
  13. Що таке Session Fixation і яке єдине ефективне рішення проти неї?
  14. Поясніть різницю між ETag і Last-Modified для conditional GET. Коли ETag точніший?
  15. Що таке Content-Security-Policy і як він допомагає навіть за наявності XSS-вразливості в коді?
Якщо ви впевнено відповідаєте на всі питання, маєте достатню базу для вивчення наступного розділу: HTTP-сервер на C# — від сокету до ASP.NET Core. Там ми перейдемо на сторону сервера і розберемо, як приймати та обробляти ці самі запити.

Повний робочий приклад: HttpPlayground

Консольний застосунок, що демонструє всі ключові концепції HTTP Advanced із реальними публічними API — без моків, без localhost, прямий копіпаст і запуск.

Структура та залежності

dotnet new console -n HttpPlayground
cd HttpPlayground
dotnet run

Потрібен лише .NET 8+. Жодних додаткових пакетів — System.Net.Http.Json та System.Text.Json входять у SDK.

Program.cs — повний код

// HttpPlayground — демонстрація HTTP Advanced з реальними API
// Запуск: dotnet new console -n HttpPlayground && cd HttpPlayground && dotnet run

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;

Console.OutputEncoding = Encoding.UTF8;

// ════════════════════════════════════════════════════════════════════════════
// 1. JWT АУТЕНТИФІКАЦІЯ — dummyjson.com
// ════════════════════════════════════════════════════════════════════════════
Console.WriteLine("═══ 1. JWT Authentication (dummyjson.com) ═══");

using var jwtClient = new HttpClient { BaseAddress = new Uri("https://dummyjson.com/") };

// POST /auth/login → справжній JWT access + refresh token
var loginResp = await jwtClient.PostAsJsonAsync("auth/login", new
{
    username = "emilys",
    password = "emilyspass",
    expiresInMins = 30
});
loginResp.EnsureSuccessStatusCode();

var tokenData = await loginResp.Content.ReadFromJsonAsync<JsonElement>();
string accessToken = tokenData.GetProperty("accessToken").GetString()!;
string refreshToken = tokenData.GetProperty("refreshToken").GetString()!;
Console.WriteLine($"✅ JWT отримано: {accessToken[..40]}...");

// Декодуємо payload (Base64URL) — демонстрація структури JWT без перевірки підпису
string[] parts = accessToken.Split('.');
string payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(PadBase64(parts[1])));
var payload = JsonDocument.Parse(payloadJson).RootElement;
Console.WriteLine($"   sub (id):  {payload.GetProperty("id")}");
Console.WriteLine($"   username:  {payload.GetProperty("username").GetString()}");

// Захищений запит — GET /auth/me з Bearer токеном
jwtClient.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);

var meResp = await jwtClient.GetAsync("auth/me");
meResp.EnsureSuccessStatusCode();
var me = await meResp.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine($"   Профіль:   {me.GetProperty("firstName")} {me.GetProperty("lastName")}");
Console.WriteLine($"   Email:     {me.GetProperty("email")}");

// Оновлення токену через Refresh Token
var refreshResp = await jwtClient.PostAsJsonAsync("auth/refresh",
    new { refreshToken, expiresInMins = 30 });
var newTokens = await refreshResp.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine($"✅ Токен оновлено: {newTokens.GetProperty("accessToken").GetString()![..40]}...");

// ════════════════════════════════════════════════════════════════════════════
// 2. COOKIE MANAGEMENT — httpbingo.org
// ════════════════════════════════════════════════════════════════════════════
Console.WriteLine("\n═══ 2. Cookie Management (httpbingo.org) ═══");

var cookieContainer = new CookieContainer();
var cookieHandler = new HttpClientHandler { CookieContainer = cookieContainer };
using var cookieClient = new HttpClient(cookieHandler)
{
    BaseAddress = new Uri("https://httpbingo.org/")
};

// /cookies/set встановлює cookie через 302-редирект
// CookieContainer автоматично слідує і зберігає Set-Cookie
await cookieClient.GetAsync("cookies/set?session=token_xyz123");
await cookieClient.GetAsync("cookies/set?theme=dark");

var storedCookies = cookieContainer.GetCookies(new Uri("https://httpbingo.org/"));
Console.WriteLine($"✅ CookieContainer має {storedCookies.Count} cookies:");
foreach (Cookie c in storedCookies)
    Console.WriteLine($"   {c.Name}={c.Value}  HttpOnly={c.HttpOnly}");

// CookieContainer автоматично надсилає cookies у наступних запитах
var cookiesResp = await cookieClient.GetAsync("cookies");
var cookieBody = await cookiesResp.Content.ReadFromJsonAsync<JsonElement>();
Console.Write("   httpbingo бачить: ");
foreach (var prop in cookieBody.GetProperty("cookies").EnumerateObject())
    Console.Write($"{prop.Name}={prop.Value}  ");
Console.WriteLine();

// ════════════════════════════════════════════════════════════════════════════
// 3. BASIC AUTH — httpbingo.org
// ════════════════════════════════════════════════════════════════════════════
Console.WriteLine("\n═══ 3. Basic Auth (httpbingo.org) ═══");

using var basicClient = new HttpClient();

// Невірні облікові дані → 401 Unauthorized
var wrongReq = new HttpRequestMessage(HttpMethod.Get,
    "https://httpbingo.org/basic-auth/alice/secret");
wrongReq.Headers.Authorization = new AuthenticationHeaderValue(
    "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("alice:wrong")));
var wrongResp = await basicClient.SendAsync(wrongReq);
Console.WriteLine($"✅ Невірний пароль: {wrongResp.StatusCode}");

// Правильні облікові дані → 200 OK
var rightReq = new HttpRequestMessage(HttpMethod.Get,
    "https://httpbingo.org/basic-auth/alice/secret");
rightReq.Headers.Authorization = new AuthenticationHeaderValue(
    "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("alice:secret")));
var rightResp = await basicClient.SendAsync(rightReq);
var authBody = await rightResp.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine($"✅ Правильні дані: {rightResp.StatusCode}, authenticated={authBody.GetProperty("authenticated")}");

// ════════════════════════════════════════════════════════════════════════════
// 4. ETAG CACHING — httpbingo.org
// ════════════════════════════════════════════════════════════════════════════
Console.WriteLine("\n═══ 4. ETag Caching (httpbingo.org) ═══");

using var cacheClient = new HttpClient { BaseAddress = new Uri("https://httpbingo.org/") };

// Перший запит — отримуємо ETag
var firstResp = await cacheClient.GetAsync("etag/v7-product-list");
string etag = firstResp.Headers.ETag?.Tag ?? "";
long firstSize = (await firstResp.Content.ReadAsByteArrayAsync()).Length;
Console.WriteLine($"✅ GET /etag/v7-product-list: {firstResp.StatusCode}, ETag={etag}, {firstSize} байт");

// Conditional GET — If-None-Match: якщо ресурс не змінився → 304 (0 байт тіла)
var condReq = new HttpRequestMessage(HttpMethod.Get, "etag/v7-product-list");
condReq.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(etag));
var condResp = await cacheClient.SendAsync(condReq);
Console.WriteLine($"✅ Conditional GET: {condResp.StatusCode} " +
    $"— тіло {(condResp.StatusCode == HttpStatusCode.NotModified ? "відсутнє (0 байт!)" : "отримано")}");

// ════════════════════════════════════════════════════════════════════════════
// 5. COMPRESSION — httpbingo.org
// ════════════════════════════════════════════════════════════════════════════
Console.WriteLine("\n═══ 5. Compression (httpbingo.org) ═══");

var compHandler = new HttpClientHandler
{
    AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};
using var compClient = new HttpClient(compHandler);

// /gzip — завжди повертає gzip-стиснений JSON
var gzipResp = await compClient.GetAsync("https://httpbingo.org/gzip");
string gzipBody = await gzipResp.Content.ReadAsStringAsync();
Console.WriteLine($"✅ GZIP: {gzipResp.StatusCode}, розпаковано {gzipBody.Length} символів");

Console.WriteLine("\n═══ Готово! Всі 5 концепцій продемонстровано. ═══");

// ── Допоміжна функція ────────────────────────────────────────────────────────
static string PadBase64(string base64Url)
{
    string base64 = base64Url.Replace('-', '+').Replace('_', '/');
    return base64.Length % 4 switch
    {
        2 => base64 + "==",
        3 => base64 + "=",
        _ => base64
    };
}
dotnet run — HttpPlayground
═══ 1. JWT Authentication (dummyjson.com) ═══
✅ JWT отримано: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
sub (id): 1
username: emilys
Профіль: Emily Johnson
Email: emily.johnson@x.dummyjson.com
✅ Токен оновлено: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
═══ 2. Cookie Management (httpbingo.org) ═══
✅ CookieContainer має 2 cookies:
session=token_xyz123 HttpOnly=True
theme=dark HttpOnly=True
httpbingo бачить: session=token_xyz123 theme=dark
═══ 3. Basic Auth (httpbingo.org) ═══
✅ Невірний пароль: Unauthorized
✅ Правильні дані: OK, authenticated=true
═══ 4. ETag Caching (httpbingo.org) ═══
✅ GET /etag/v7-product-list: OK, ETag="v7-product-list", 426 байт
✅ Conditional GET: NotModified — тіло відсутнє (0 байт!)
═══ 5. Compression (httpbingo.org) ═══
✅ GZIP: OK, розпаковано 226 символів
═══ Готово! Всі 5 концепцій продемонстровано. ═══
Copyright © 2026