Auth

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

Повна реалізація JWT Bearer у Minimal API: генерація токенів, login/refresh ендпоінти, TokenValidationParameters, зберігання секретів, Access + Refresh Token стратегія.

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

У попередній статті ми вивчили модель Claims та загальний потік аутентифікації. Тепер час реалізувати найпоширеніший спосіб аутентифікації для API — JWT Bearer. Ми пройдемо від розбору структури токена до повноцінних ендпоінтів логіну та оновлення токенів.

1. Що таке JWT і як він працює?

Проблема: stateless-аутентифікація

HTTP — протокол без стану. Сервер не пам'ятає попередні запити. Як тоді зрозуміти, що запит #2 і запит #57 — від того самого автентифікованого користувача?

ПідхідДе зберігається станПроблеми
СесіїНа сервері (Redis, Memory)Не масштабується: 10 серверів = 10 копій сесії
JWTУ самому токеніСервер нічого не зберігає — всё в токені

JWT (JSON Web Token, вимовляється «джот») — це самодостатній токен, який містить всю необхідну інформацію про користувача, захищену цифровим підписом.

Анатомія JWT

JWT складається з трьох частин, розділених крапкою:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk
── Header ──────────  ── Payload ──────  ── Signature ─────────────────────────
{
    "alg": "HS256",
    "typ": "JWT"
}

Метадані: алгоритм підпису (HS256 = HMAC-SHA256) та тип токена. Кодується у Base64Url.

Критично: JWT підписаний, але НЕ зашифрований! Payload декодується простим Base64. Тому ніколи не кладіть у JWT: паролі, номери карток, персональні дані. Тільки ID, роль, email — те, що не секретне.

2. Налаштування JWT Bearer в Minimal API

Крок за кроком

Встановлюємо NuGet-пакет

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Налаштовуємо сервіси

Program.cs — налаштування JWT
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

// Секретний ключ (мінімум 32 символи для HS256!)
var jwtKey = builder.Configuration["Jwt:Key"]
    ?? throw new InvalidOperationException(
        "JWT Key is not configured!");

var keyBytes = Encoding.UTF8.GetBytes(jwtKey);

builder.Services
    .AddAuthentication(JwtBearerDefaults
        .AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new()
        {
            // ✅ Перевіряти підпис
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                keyBytes),

            // ✅ Перевіряти видавця (хто створив токен)
            ValidateIssuer = true,
            ValidIssuer = "MyApi",

            // ✅ Перевіряти аудиторію (для кого токен)
            ValidateAudience = true,
            ValidAudience = "MyApp",

            // ✅ Перевіряти час дії
            ValidateLifetime = true,

            // 0 толерантності до прострочених токенів
            ClockSkew = TimeSpan.Zero
        };
    });

builder.Services.AddAuthorization();

Додаємо Middleware

Program.cs — middleware
var app = builder.Build();

app.UseAuthentication();  // 1️⃣ Хто ти?
app.UseAuthorization();   // 2️⃣ Чи маєш право?

// ... ендпоінти далі

app.Run();

Параметри TokenValidationParameters

ValidateIssuerSigningKey
bool
Перевіряти цифровий підпис токена. Завжди true — інакше будь-хто зможе підробити токен.
IssuerSigningKey
SecurityKey
Ключ для перевірки підпису. Для HMAC (симетричний) — SymmetricSecurityKey. Для RSA (асиметричний) — RsaSecurityKey.
ValidateIssuer
bool
Перевіряти поле iss (issuer) у токені. Захищає від використання токенів від іншого API.
ValidIssuer
string
Очікуване значення iss. Повинно збігатися з тим, що ми вказуємо при генерації токена.
ValidateAudience
bool
Перевіряти поле aud (audience). Визначає, для кого призначений токен.
ValidateLifetime
bool
Перевіряти, чи не прострочений токен (поле exp). Завжди true у production.
ClockSkew
TimeSpan
Допустима різниця між годинниками сервера та часом у токені. За замовчуванням — 5 хвилин (токен прострочений 3 хвилини тому — все ще приймається!). Для жорсткої безпеки: TimeSpan.Zero.

3. Генерація JWT-токенів

Тепер нам потрібно створювати токени — при логіні та при оновленні. Створимо окремий сервіс:

Services/TokenService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;

public class TokenService
{
    private readonly IConfiguration _config;

    public TokenService(IConfiguration config)
    {
        _config = config;
    }

    public string GenerateAccessToken(
        int userId, string name, string email,
        IEnumerable<string> roles)
    {
        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(
                _config["Jwt:Key"]!));

        var credentials = new SigningCredentials(
            key, SecurityAlgorithms.HmacSha256);

        // Claims — «начинка» токена
        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub,
                userId.ToString()),
            new(JwtRegisteredClaimNames.Name, name),
            new(JwtRegisteredClaimNames.Email, email),
            new(JwtRegisteredClaimNames.Jti,
                Guid.NewGuid().ToString()),
        };

        // Кожна роль — окремий claim
        foreach (var role in roles)
            claims.Add(new(ClaimTypes.Role, role));

        var token = new JwtSecurityToken(
            issuer: _config["Jwt:Issuer"],
            audience: _config["Jwt:Audience"],
            claims: claims,
            expires: DateTime.UtcNow
                .AddMinutes(15),      // ⏱️ 15 хвилин!
            signingCredentials: credentials);

        return new JwtSecurityTokenHandler()
            .WriteToken(token);
    }

    public string GenerateRefreshToken()
    {
        // Refresh token — просто випадковий рядок
        // (не JWT! зберігається в БД)
        return Convert.ToBase64String(
            System.Security.Cryptography
                .RandomNumberGenerator.GetBytes(64));
    }
}

Розберемо ключові частини:

  • JwtRegisteredClaimNames — стандартні імена claims з RFC 7519: Sub (subject = user_id), Name, Email, Jti (JWT ID — унікальний ідентифікатор токена).
  • SigningCredentials — визначає алгоритм підпису (HmacSha256).
  • expires — через 15 хвилин токен перестане працювати.
  • Refresh Token — це не JWT, а просто безпечний випадковий рядок. Він зберігається в БД і використовується лише для отримання нового access token.

4. Ендпоінти: Login та Refresh

Login — отримання токенів

Endpoints/AuthEndpoints.cs
public static class AuthEndpoints
{
    public static WebApplication MapAuthEndpoints(
        this WebApplication app)
    {
        var group = app.MapGroup("/auth")
            .WithTags("Authentication");

        group.MapPost("/login", Login);
        group.MapPost("/refresh", Refresh);
        group.MapPost("/logout", Logout)
            .RequireAuthorization();

        return app;
    }

    private static IResult Login(
        LoginRequest req,
        TokenService tokenService,
        UserRepository userRepo)
    {
        // 1. Знаходимо користувача
        var user = userRepo.FindByEmail(req.Email);
        if (user is null)
            return Results.Json(
                new ProblemDetails
                {
                    Title = "Invalid credentials",
                    Status = 401,
                    Detail = "Invalid email or password."
                }, statusCode: 401);

        // 2. Перевіряємо пароль
        if (!BCrypt.Net.BCrypt.Verify(
                req.Password, user.PasswordHash))
            return Results.Json(
                new ProblemDetails
                {
                    Title = "Invalid credentials",
                    Status = 401,
                    Detail = "Invalid email or password."
                }, statusCode: 401);

        // 3. Генеруємо токени
        var accessToken = tokenService
            .GenerateAccessToken(
                user.Id, user.Name,
                user.Email, user.Roles);

        var refreshToken = tokenService
            .GenerateRefreshToken();

        // 4. Зберігаємо refresh token у БД
        userRepo.SaveRefreshToken(
            user.Id, refreshToken,
            DateTime.UtcNow.AddDays(7));

        // 5. Повертаємо обидва токени
        return Results.Ok(new AuthResponse(
            AccessToken: accessToken,
            RefreshToken: refreshToken,
            ExpiresIn: 900));  // 15 хв = 900 сек
    }

    // ... Refresh та Logout далі
}

record LoginRequest(string Email, string Password);

record AuthResponse(
    string AccessToken,
    string RefreshToken,
    int ExpiresIn);
Зверніть увагу: повідомлення про помилку "Invalid email or password" однакове і для невірного email, і для невірного пароля. Це навмисно — не розкриваємо зловмиснику, що саме неправильне (як ми вивчили у статті 08 про обробку помилок).

Refresh — оновлення Access Token

Коли access token спливає (через 15 хвилин), клієнт використовує refresh token для отримання нового:

Ендпоінт Refresh
private static IResult Refresh(
    RefreshRequest req,
    TokenService tokenService,
    UserRepository userRepo)
{
    // 1. Знаходимо refresh token у БД
    var stored = userRepo.FindRefreshToken(
        req.RefreshToken);

    if (stored is null)
        return Results.Json(
            new ProblemDetails
            {
                Title = "Invalid refresh token",
                Status = 401,
                Detail = "Refresh token not found " +
                    "or revoked."
            }, statusCode: 401);

    // 2. Перевіряємо чи не прострочений
    if (stored.ExpiresAt < DateTime.UtcNow)
    {
        // Видаляємо прострочений токен
        userRepo.RevokeRefreshToken(
            req.RefreshToken);

        return Results.Json(
            new ProblemDetails
            {
                Title = "Refresh token expired",
                Status = 401,
                Detail = "Please login again."
            }, statusCode: 401);
    }

    // 3. Отримуємо користувача
    var user = userRepo.FindById(stored.UserId);
    if (user is null)
        return Results.Json(
            new ProblemDetails
            {
                Title = "User not found",
                Status = 401
            }, statusCode: 401);

    // 4. Генеруємо НОВІ токени (ротація!)
    var newAccessToken = tokenService
        .GenerateAccessToken(
            user.Id, user.Name,
            user.Email, user.Roles);

    var newRefreshToken = tokenService
        .GenerateRefreshToken();

    // 5. Замінюємо старий refresh на новий
    userRepo.RevokeRefreshToken(req.RefreshToken);
    userRepo.SaveRefreshToken(
        user.Id, newRefreshToken,
        DateTime.UtcNow.AddDays(7));

    return Results.Ok(new AuthResponse(
        AccessToken: newAccessToken,
        RefreshToken: newRefreshToken,
        ExpiresIn: 900));
}

record RefreshRequest(string RefreshToken);
Ротація Refresh Token: кожен раз при використанні ми замінюємо старий refresh token на новий. Якщо зловмисник вкрав старий токен і спробує його використати — він вже буде відкликаний. Це називається Refresh Token Rotation і є найкращою практикою безпеки.

Logout — відкликання Refresh Token

Ендпоінт Logout
private static IResult Logout(
    LogoutRequest req,
    UserRepository userRepo)
{
    // Просто видаляємо refresh token з БД
    userRepo.RevokeRefreshToken(req.RefreshToken);

    return Results.Ok(new { message = "Logged out" });
}

record LogoutRequest(string RefreshToken);

5. Повний цикл аутентифікації

Loading diagram...
sequenceDiagram
    participant C as 📱 Клієнт
    participant A as 🔑 /auth/login
    participant S as 🛡️ /api/orders
    participant R as 🔄 /auth/refresh

    C->>A: POST /auth/login<br/>{email, password}
    A-->>C: 200 OK<br/>{access_token, refresh_token, expires_in: 900}

    Note over C: Зберігає обидва токени

    C->>S: GET /api/orders<br/>Authorization: Bearer eyJ...
    S-->>C: 200 OK — список замовлень

    Note over C: Через 15 хвилин...

    C->>S: GET /api/orders<br/>Authorization: Bearer eyJ... (прострочений)
    S-->>C: 401 Unauthorized

    C->>R: POST /auth/refresh<br/>{refresh_token: "abc..."}
    R-->>C: 200 OK<br/>{new access_token, new refresh_token}

    Note over C: Продовжує працювати<br/>з новими токенами

6. Де зберігати секретний ключ?

Секретний ключ JWT — це найважливіший секрет вашого API. Хто знає ключ — може створити токен для будь-якого користувача з будь-якими правами.

User Secrets — безпечне сховище для локальної розробки:

# Ініціалізація (один раз)
dotnet user-secrets init

# Додавання секрету
dotnet user-secrets set "Jwt:Key" \
    "MySuperSecretKeyThatIsAtLeast32CharactersLong!!"

Секрет зберігається у домашній директорії (не в проєкті!) і не потрапляє в Git.

appsettings.json — без секретів!
{
  "Jwt": {
    "Issuer": "MyApi",
    "Audience": "MyApp"
  }
}
// ❌ Jwt:Key тут НЕМАЄ!
// ✅ Він у User Secrets
Ніколи не зберігайте JWT-ключ у appsettings.json або в коді! appsettings.json потрапляє в Git → ваш секрет публічний. Одне правило: якщо секрет у Git — він скомпрометований.

7. Конфігурація через appsettings

Рекомендована структура конфігурації:

appsettings.json
{
    "Jwt": {
        "Issuer": "MyApi",
        "Audience": "MyApp",
        "AccessTokenExpirationMinutes": 15,
        "RefreshTokenExpirationDays": 7
    }
}
Використання в TokenService
public string GenerateAccessToken(/* ... */)
{
    var minutes = int.Parse(
        _config["Jwt:AccessTokenExpirationMinutes"]
            ?? "15");

    var token = new JwtSecurityToken(
        issuer: _config["Jwt:Issuer"],
        audience: _config["Jwt:Audience"],
        claims: claims,
        expires: DateTime.UtcNow
            .AddMinutes(minutes),
        signingCredentials: credentials);

    return new JwtSecurityTokenHandler()
        .WriteToken(token);
}

8. Повний робочий приклад

Program.cs — повний JWT auth
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

// JWT конфігурація
var jwtKey = builder.Configuration["Jwt:Key"]
    ?? throw new InvalidOperationException(
        "Jwt:Key not configured!");

builder.Services
    .AddAuthentication(
        JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new()
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(jwtKey)),
            ValidateIssuer = true,
            ValidIssuer = "MyApi",
            ValidateAudience = true,
            ValidAudience = "MyApp",
            ValidateLifetime = true,
            ClockSkew = TimeSpan.Zero
        };
    });

builder.Services.AddAuthorization();
builder.Services.AddSingleton<TokenService>();
builder.Services.AddSingleton<InMemoryUserStore>();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

// === AUTH ===
app.MapPost("/auth/register",
    (RegisterRequest req, InMemoryUserStore store) =>
{
    if (store.FindByEmail(req.Email) is not null)
        return Results.Conflict(new
        {
            error = "User already exists"
        });

    var user = new User
    {
        Id = store.NextId(),
        Name = req.Name,
        Email = req.Email,
        PasswordHash = BCrypt.Net.BCrypt
            .HashPassword(req.Password),
        Roles = ["User"]
    };

    store.Add(user);
    return Results.Created($"/users/{user.Id}",
        new { user.Id, user.Name, user.Email });
});

app.MapPost("/auth/login",
    (LoginRequest req,
     InMemoryUserStore store,
     TokenService tokens) =>
{
    var user = store.FindByEmail(req.Email);
    if (user is null || !BCrypt.Net.BCrypt
            .Verify(req.Password, user.PasswordHash))
        return Results.Json(
            new { error = "Invalid credentials" },
            statusCode: 401);

    var accessToken = tokens.GenerateAccessToken(
        user.Id, user.Name, user.Email, user.Roles);
    var refreshToken = tokens.GenerateRefreshToken();

    store.SaveRefreshToken(
        user.Id, refreshToken,
        DateTime.UtcNow.AddDays(7));

    return Results.Ok(new
    {
        access_token = accessToken,
        refresh_token = refreshToken,
        expires_in = 900
    });
});

// === PROTECTED ===
app.MapGet("/me", (ClaimsPrincipal user) =>
{
    return Results.Ok(new
    {
        id = user.FindFirst(
            JwtRegisteredClaimNames.Sub)?.Value,
        name = user.Identity?.Name,
        email = user.FindFirst(
            JwtRegisteredClaimNames.Email)?.Value
    });
}).RequireAuthorization();

app.Run();

// === MODELS ===
record RegisterRequest(
    string Name, string Email, string Password);
record LoginRequest(string Email, string Password);

class User
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Email { get; set; } = "";
    public string PasswordHash { get; set; } = "";
    public List<string> Roles { get; set; } = [];
}

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

Рівень 1: Базовий

Рівень 2: Проєктування

Рівень 3: Архітектура


10. Резюме

JWT = Header.Payload.Signature

Самодостатній токен з claims. Підписаний, але НЕ зашифрований. Не зберігайте секрети у payload!

Access + Refresh Token

Access — короткоживучий (15 хв), для кожного запиту. Refresh — довгоживучий (7 днів), зберігається в БД, використовується для ротації.

TokenValidationParameters

Завжди: ValidateIssuerSigningKey = true, ValidateLifetime = true, ClockSkew = Zero. Без цього — фальшиві токени пройдуть валідацію.

Секрети — поза кодом

User Secrets для dev, Environment Variables для prod. Ніколи не в appsettings.json чи Git.

Далі: у наступній статті ми розберемо авторизацію — ролі, політики, Resource-based авторизація та кастомні IAuthorizationHandler.

Copyright © 2026