JWT-аутентифікація
JWT-аутентифікація
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.
{
"sub": "42",
"name": "Іван Петренко",
"email": "ivan@example.com",
"role": "Admin",
"iat": 1709380000,
"exp": 1709380900,
"iss": "MyApi",
"aud": "MyApp"
}
Claims — дані про користувача. Кодується у Base64Url. Не зашифровано — будь-хто може прочитати! Тому тут не можна зберігати паролі або секрети.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret_key
)
Цифровий підпис. Гарантує, що payload не було змінено. Тільки сервер знає secret_key, тому підробити підпис неможливо (без знання ключа).
2. Налаштування JWT Bearer в Minimal API
Крок за кроком
Встановлюємо NuGet-пакет
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
Налаштовуємо сервіси
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
var app = builder.Build();
app.UseAuthentication(); // 1️⃣ Хто ти?
app.UseAuthorization(); // 2️⃣ Чи маєш право?
// ... ендпоінти далі
app.Run();
Параметри TokenValidationParameters
true — інакше будь-хто зможе підробити токен.SymmetricSecurityKey. Для RSA (асиметричний) — RsaSecurityKey.iss (issuer) у токені. Захищає від використання токенів від іншого API.iss. Повинно збігатися з тим, що ми вказуємо при генерації токена.aud (audience). Визначає, для кого призначений токен.exp). Завжди true у production.TimeSpan.Zero.3. Генерація JWT-токенів
Тепер нам потрібно створювати токени — при логіні та при оновленні. Створимо окремий сервіс:
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 — отримання токенів
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 для отримання нового:
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);
Logout — відкликання Refresh Token
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. Повний цикл аутентифікації
6. Де зберігати секретний ключ?
Секретний ключ JWT — це найважливіший секрет вашого API. Хто знає ключ — може створити токен для будь-якого користувача з будь-якими правами.
User Secrets — безпечне сховище для локальної розробки:
# Ініціалізація (один раз)
dotnet user-secrets init
# Додавання секрету
dotnet user-secrets set "Jwt:Key" \
"MySuperSecretKeyThatIsAtLeast32CharactersLong!!"
Секрет зберігається у домашній директорії (не в проєкті!) і не потрапляє в Git.
{
"Jwt": {
"Issuer": "MyApi",
"Audience": "MyApp"
}
}
// ❌ Jwt:Key тут НЕМАЄ!
// ✅ Він у User Secrets
Environment Variables або Vault-сервіси:
# Linux / Docker
export Jwt__Key="ProductionSuperSecretKey..."
# Docker Compose
environment:
- Jwt__Key=ProductionSuperSecretKey...
ASP.NET Core автоматично підхоплює env-змінні з роздільником __ (замість :).
Для Enterprise-додатків — Azure Key Vault, AWS Secrets Manager або HashiCorp Vault.
appsettings.json або в коді! appsettings.json потрапляє в Git → ваш секрет публічний. Одне правило: якщо секрет у Git — він скомпрометований.7. Конфігурація через appsettings
Рекомендована структура конфігурації:
{
"Jwt": {
"Issuer": "MyApi",
"Audience": "MyApp",
"AccessTokenExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7
}
}
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. Повний робочий приклад
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: Базовий
Реалізуйте базову JWT-аутентифікацію:
- Встановіть пакет
Microsoft.AspNetCore.Authentication.JwtBearer - Налаштуйте
AddJwtBearer()зTokenValidationParameters - Створіть
POST /auth/loginщо приймає email/password і повертає JWT-токен - Створіть
GET /meз.RequireAuthorization()що повертає claims з токена - Протестуйте: запит до
/meбез токена →401, з токеном →200 - Декодуйте ваш токен на jwt.io — які claims бачите?
Перенесіть JWT-ключ у безпечне сховище:
- Виконайте
dotnet user-secrets init - Додайте ключ:
dotnet user-secrets set "Jwt:Key" "YourSecret..." - Переконайтесь, що
appsettings.jsonне містить ключ - Перевірте, що додаток все ще працює
- Знайдіть, де фізично зберігаються user secrets на вашій ОС
Рівень 2: Проєктування
Реалізуйте повний цикл з двома токенами:
POST /auth/login→ повертаєaccess_token(15 хв) +refresh_token(7 днів)POST /auth/refresh→ приймаєrefresh_token, повертає нову пару токенівPOST /auth/logout→ відкликає refresh token- Refresh token зберігається в
Dictionary<string, RefreshTokenInfo>(in-memory) - Реалізуйте ротацію: при refresh старий токен видаляється, створюється новий
- Перевірте: використання старого refresh token після ротації →
401
Додайте реєстрацію з безпечним зберіганням паролів:
- Встановіть
BCrypt.Net-Next POST /auth/register— зберігаєBCrypt.HashPassword(password), не plaintextPOST /auth/login— перевіряє черезBCrypt.Verify(password, hash)- Перевірте: два однакові паролі дають різні хеші (через salt)
- Додайте валідацію: пароль мінімум 8 символів, email унікальний
Рівень 3: Архітектура
Виділіть генерацію токенів у окремий тестований сервіс:
- Створіть
TokenServiceз методамиGenerateAccessToken()таGenerateRefreshToken() TokenServiceотримуєIConfigurationчерез DI- Зареєструйте як
Singletonу DI-контейнері - Створіть
UserRepository(in-memory) для зберігання користувачів і refresh tokens - Винесіть auth ендпоінти в
AuthEndpoints.cs(Extension Method) Program.cs— не більше 30 рядків
10. Резюме
JWT = Header.Payload.Signature
Access + Refresh Token
TokenValidationParameters
Секрети — поза кодом
Далі: у наступній статті ми розберемо авторизацію — ролі, політики, Resource-based авторизація та кастомні IAuthorizationHandler.
Основи аутентифікації та авторизації
Authentication vs Authorization, Claims-модель ідентичності .NET, ClaimsPrincipal, AuthenticationMiddleware, схеми аутентифікації та повний потік обробки запиту.
Авторизація: ролі, політики та resource-based доступ
Повна система авторизації у Minimal API: RequireAuthorization, ролі, кастомні політики, IAuthorizationRequirement, IAuthorizationHandler, resource-based авторизація.