Refresh Token Rotation в ASP.NET Core
Refresh Token Rotation в ASP.NET Core
1. Проблема довгоживучих токенів
Чому JWT не відкликати?
JWT — stateless. Сервер не зберігає список виданих токенів — він лише перевіряє підпис та термін дії. Тому неможливо «відкликати» окремий JWT без централізованого blacklist.
Компроміс Access Token:
├── Термін дії: 15 хвилин
├── Зловмисник використовує: макс 15 хвилин
└── Збиток: обмежений часом
Компроміс Refresh Token:
├── Термін дії: 30 днів
├── Зловмисник може оновлювати нескінченно
└── Збиток: довгостроковий злам аккаунту
Solution: Rotation + Detection
Rotation: при кожному refresh → старий token видаляється, видається новий. Зловмисник не може використати вкрадений token двічі.
Detection: якщо вкрадений token використали раніше за легітимного клієнта → обидва виявляють спробу повторного використання → анулюємо всю сім'ю токенів.
2. Модель даних
public class RefreshToken
{
public Guid Id { get; set; } = Guid.NewGuid();
public string UserId { get; set; } = null!;
// Значення токена (хешоване у БД)
public string TokenHash { get; set; } = null!;
// «Сім'я» токенів — ланцюжок rotation від початкового логіну
// Якщо старий token з тієї ж сім'ї повторно використовується → зломwu
public string FamilyId { get; set; } = null!;
// Метадані
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime ExpiresAt { get; set; }
public DateTime? UsedAt { get; set; } // null = ще не використаний
public bool IsRevoked { get; set; } = false;
// Для слідування ланцюжку (debugging)
public Guid? PreviousTokenId { get; set; }
// Контекст видачі
public string? UserAgent { get; set; }
public string? IpAddress { get; set; }
public AppUser User { get; set; } = null!;
}
public DbSet<RefreshToken> RefreshTokens { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<RefreshToken>(e =>
{
// Унікальний хеш для швидкого lookup
e.HasIndex(t => t.TokenHash).IsUnique();
// Індекс по FamilyId для швидкого анулювання сім'ї
e.HasIndex(t => t.FamilyId);
// Індекс по UserId + IsRevoked для cleanup
e.HasIndex(t => new { t.UserId, t.IsRevoked });
e.HasOne(t => t.User)
.WithMany()
.HasForeignKey(t => t.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
3. TokenService: генерація та валідація
public class TokenService
{
private readonly IConfiguration _config;
private readonly AppDbContext _db;
private readonly ILogger<TokenService> _logger;
public TokenService(
IConfiguration config,
AppDbContext db,
ILogger<TokenService> logger)
{
_config = config;
_db = db;
_logger = logger;
}
// ─── Access Token (JWT) ─────────────────────────────────────
public string GenerateAccessToken(AppUser user, IList<string> roles)
{
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!));
var credential = new SigningCredentials(
key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Email, user.Email!),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new("username", user.UserName!)
};
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
notBefore: DateTime.UtcNow,
expires: DateTime.UtcNow.AddMinutes(15), // Короткий термін!
signingCredentials: credential);
return new JwtSecurityTokenHandler().WriteToken(token);
}
// ─── Refresh Token ──────────────────────────────────────────
/// <summary>
/// Генерує новий refresh token (початок або продовження ланцюжка).
/// </summary>
public async Task<string> GenerateRefreshTokenAsync(
string userId,
string? familyId = null, // null = новий login = нова сім'я
Guid? previousTokenId = null,
string? ipAddress = null,
string? userAgent = null)
{
// Справжній випадковий токен: 64 байти = 512 біт
var tokenBytes = RandomNumberGenerator.GetBytes(64);
var tokenValue = Convert.ToBase64String(tokenBytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
var tokenHash = ComputeHash(tokenValue);
var refreshToken = new RefreshToken
{
UserId = userId,
TokenHash = tokenHash,
FamilyId = familyId ?? Guid.NewGuid().ToString(),
ExpiresAt = DateTime.UtcNow.AddDays(
int.Parse(_config["Jwt:RefreshTokenDays"] ?? "30")),
PreviousTokenId = previousTokenId,
IpAddress = ipAddress,
UserAgent = userAgent,
};
_db.RefreshTokens.Add(refreshToken);
await _db.SaveChangesAsync();
return tokenValue; // Повертаємо plaintext
}
/// <summary>
/// Валідує refresh token з перевіркою Token Reuse Detection.
/// </summary>
public async Task<RefreshTokenValidationResult> ValidateRefreshTokenAsync(
string tokenValue,
string? ipAddress = null)
{
var tokenHash = ComputeHash(tokenValue);
// Знаходимо токен у БД (по хешу)
var token = await _db.RefreshTokens
.Include(t => t.User)
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash);
if (token is null)
{
_logger.LogWarning(
"Unknown refresh token used from IP: {IP}", ipAddress);
return RefreshTokenValidationResult.Invalid("Token not found.");
}
// ─── Token Reuse Detection ───────────────────────────────
if (token.UsedAt is not null || token.IsRevoked)
{
_logger.LogWarning(
"⚠️ REUSE DETECTED: Refresh token {Id} (family {Family}) " +
"was already used at {UsedAt}. Possible theft! " +
"Revoking entire family for user {UserId}.",
token.Id, token.FamilyId, token.UsedAt, token.UserId);
// КРИТИЧНО: анулюємо ВСЮ сім'ю токенів
// Це виштовхне зловмисника та легітимного клієнта
await RevokeFamilyAsync(token.FamilyId, token.UserId);
return RefreshTokenValidationResult.Invalid(
"Token reuse detected. All sessions for this device have been revoked.");
}
// Перевіряємо термін дії
if (token.ExpiresAt < DateTime.UtcNow)
{
token.IsRevoked = true;
await _db.SaveChangesAsync();
return RefreshTokenValidationResult.Invalid("Token expired.");
}
// Позначаємо токен як використаний (але ще не видаляємо — для detection)
token.UsedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return RefreshTokenValidationResult.Valid(token);
}
/// <summary>
/// Відкликання всієї сім'ї токенів (при виявленні reuse або logout).
/// </summary>
public async Task RevokeFamilyAsync(string familyId, string userId)
{
await _db.RefreshTokens
.Where(t => t.FamilyId == familyId && t.UserId == userId)
.ExecuteUpdateAsync(s =>
s.SetProperty(t => t.IsRevoked, true));
_logger.LogInformation(
"Revoked all tokens in family {FamilyId} for user {UserId}",
familyId, userId);
}
/// <summary>
/// Logout: відкликати тільки поточний токен.
/// </summary>
public async Task RevokeTokenAsync(string tokenValue)
{
var hash = ComputeHash(tokenValue);
var token = await _db.RefreshTokens
.FirstOrDefaultAsync(t => t.TokenHash == hash);
if (token is not null)
{
token.IsRevoked = true;
await _db.SaveChangesAsync();
}
}
private static string ComputeHash(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLower();
}
}
// ─── Result types ─────────────────────────────────────────────────
public record RefreshTokenValidationResult(
bool IsValid,
RefreshToken? Token,
string? Error)
{
public static RefreshTokenValidationResult Valid(RefreshToken token)
=> new(true, token, null);
public static RefreshTokenValidationResult Invalid(string error)
=> new(false, null, error);
}
4. Ендпоінти: Login, Refresh, Logout
Login — видача пари токенів
app.MapPost("/auth/login",
async (LoginRequest req,
HttpContext ctx,
UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
TokenService tokenService) =>
{
var user = await userManager.FindByEmailAsync(req.Email);
if (user is null)
return Results.Json(
new { error = "Invalid credentials" }, statusCode: 401);
var result = await signInManager.CheckPasswordSignInAsync(
user, req.Password, lockoutOnFailure: true);
if (!result.Succeeded)
return Results.Json(
new { error = "Invalid credentials" }, statusCode: 401);
if (result.IsLockedOut)
return Results.Json(
new { error = "Account locked" }, statusCode: 423);
if (result.RequiresTwoFactor)
return Results.Ok(new { requiresTwoFactor = true, userId = user.Id });
// Генеруємо нову сім'ю токенів (початок нового login session)
var roles = await userManager.GetRolesAsync(user);
var accessToken = tokenService.GenerateAccessToken(user, roles);
var refreshToken = await tokenService.GenerateRefreshTokenAsync(
userId: user.Id,
familyId: null, // Нова сім'я при логіні
ipAddress: ctx.Connection.RemoteIpAddress?.ToString(),
userAgent: ctx.Request.Headers.UserAgent.ToString());
// Refresh token — в HttpOnly cookie (безпечніше, ніж localStorage)
ctx.Response.Cookies.Append("refresh_token", refreshToken, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Expires = DateTimeOffset.UtcNow.AddDays(30),
Path = "/auth/refresh" // Тільки для refresh ендпоінту
});
return Results.Ok(new
{
access_token = accessToken,
expires_in = 900, // 15 хвилин у секундах
token_type = "Bearer"
});
});
record LoginRequest(string Email, string Password);
Refresh — ротація пари токенів
app.MapPost("/auth/refresh",
async (HttpContext ctx,
UserManager<AppUser> userManager,
TokenService tokenService) =>
{
// Читаємо refresh token з HttpOnly cookie
var refreshTokenValue = ctx.Request.Cookies["refresh_token"];
// Альтернативно — з тіла запиту (для SPA без cookie)
if (string.IsNullOrEmpty(refreshTokenValue))
{
var body = await ctx.Request.ReadFromJsonAsync<RefreshRequest>();
refreshTokenValue = body?.RefreshToken;
}
if (string.IsNullOrEmpty(refreshTokenValue))
return Results.Json(
new { error = "Missing refresh token" }, statusCode: 401);
// Валідуємо (з reuse detection!)
var validation = await tokenService.ValidateRefreshTokenAsync(
refreshTokenValue,
ctx.Connection.RemoteIpAddress?.ToString());
if (!validation.IsValid)
return Results.Json(
new { error = validation.Error }, statusCode: 401);
var oldToken = validation.Token!;
var user = oldToken.User;
// Генеруємо НОВУ пару токенів (ротація)
var roles = await userManager.GetRolesAsync(user);
var newAccessToken = tokenService.GenerateAccessToken(user, roles);
var newRefreshToken = await tokenService.GenerateRefreshTokenAsync(
userId: user.Id,
familyId: oldToken.FamilyId, // ТА САМА сім'я
previousTokenId: oldToken.Id, // Для аудиту
ipAddress: ctx.Connection.RemoteIpAddress?.ToString(),
userAgent: ctx.Request.Headers.UserAgent.ToString());
// Оновлюємо cookie з новим refresh token
ctx.Response.Cookies.Append("refresh_token", newRefreshToken, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Expires = DateTimeOffset.UtcNow.AddDays(30),
Path = "/auth/refresh"
});
return Results.Ok(new
{
access_token = newAccessToken,
expires_in = 900,
token_type = "Bearer"
});
});
record RefreshRequest(string? RefreshToken);
Logout — відкликання токена
app.MapPost("/auth/logout",
async (HttpContext ctx,
TokenService tokenService) =>
{
var refreshTokenValue = ctx.Request.Cookies["refresh_token"];
if (!string.IsNullOrEmpty(refreshTokenValue))
await tokenService.RevokeTokenAsync(refreshTokenValue);
// Видаляємо cookie
ctx.Response.Cookies.Delete("refresh_token");
return Results.Ok(new { message = "Logged out successfully." });
}).RequireAuthorization();
app.MapPost("/auth/logout-all",
async (HttpContext ctx,
UserManager<AppUser> userManager,
TokenService tokenService,
AppDbContext db) =>
{
var userId = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
return Results.Unauthorized();
// Відкликаємо ВСІ активні refresh tokens цього користувача
await db.RefreshTokens
.Where(t => t.UserId == userId && !t.IsRevoked)
.ExecuteUpdateAsync(s =>
s.SetProperty(t => t.IsRevoked, true));
// Cookie поточного пристрою
ctx.Response.Cookies.Delete("refresh_token");
// Опціонально: оновити SecurityStamp → інвалідує cookie-сесії
var user = await userManager.FindByIdAsync(userId);
if (user is not null)
await userManager.UpdateSecurityStampAsync(user);
return Results.Ok(new
{
message = "Logged out from all devices."
});
}).RequireAuthorization();
5. Sliding Expiration
Sliding expiration — термін дії refresh token оновлюється при кожному використанні. Якщо користувач активний — токен не спливає. Якщо неактивний (30+ днів) — logout.
// У RefreshToken моделі додаємо:
public DateTime AbsoluteExpiresAt { get; set; } // Жорстке закінчення (180 днів)
// При ротації — оновлюємо ExpiresAt (але не AbsoluteExpiresAt):
var newRefreshToken = new RefreshToken
{
UserId = userId,
TokenHash = tokenHash,
FamilyId = familyId,
PreviousTokenId = previousTokenId,
// Sliding: +30 днів від зараз
ExpiresAt = DateTime.UtcNow.AddDays(30),
// Absolute: береться від попереднього токена у сім'ї (не оновлюється!)
AbsoluteExpiresAt = previousToken?.AbsoluteExpiresAt
?? DateTime.UtcNow.AddDays(180),
};
// При валідації перевіряємо ОБИДВА:
if (token.ExpiresAt < DateTime.UtcNow ||
token.AbsoluteExpiresAt < DateTime.UtcNow)
{
return RefreshTokenValidationResult.Invalid("Token expired.");
}
6. Cleanup старих токенів
Таблиця RefreshTokens буде зростати. Потрібна регулярна очистка:
public class TokenCleanupService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<TokenCleanupService> _logger;
public TokenCleanupService(
IServiceScopeFactory scopeFactory,
ILogger<TokenCleanupService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await CleanupAsync(stoppingToken);
// Раз на день
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
}
}
private async Task CleanupAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoff = DateTime.UtcNow;
// Видаляємо: прострочені, відкликані та використані старіше 7 днів
var deleted = await db.RefreshTokens
.Where(t =>
t.ExpiresAt < cutoff ||
t.IsRevoked ||
(t.UsedAt != null && t.UsedAt < cutoff.AddDays(-7)))
.ExecuteDeleteAsync(ct);
if (deleted > 0)
_logger.LogInformation(
"Cleaned up {Count} expired/revoked refresh tokens", deleted);
}
}
builder.Services.AddHostedService<TokenCleanupService>();
7. Сесії та аудит
app.MapGet("/auth/sessions",
async (HttpContext ctx, AppDbContext db) =>
{
var userId = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value!;
// Унікальні сім'ї = унікальні сесії/пристрої
var sessions = await db.RefreshTokens
.Where(t => t.UserId == userId
&& !t.IsRevoked
&& t.ExpiresAt > DateTime.UtcNow
&& t.UsedAt == null) // Лише активні (ще не використані)
.GroupBy(t => t.FamilyId)
.Select(g => g
.OrderByDescending(t => t.CreatedAt)
.First())
.Select(t => new
{
t.FamilyId,
t.IpAddress,
t.UserAgent,
t.CreatedAt,
t.ExpiresAt,
IsCurrentSession = t.FamilyId == /* поточний */ ""
})
.ToListAsync();
return Results.Ok(sessions);
}).RequireAuthorization();
// Відкликати конкретну сесію (по FamilyId)
app.MapDelete("/auth/sessions/{familyId}",
async (string familyId,
HttpContext ctx,
TokenService tokenService) =>
{
var userId = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value!;
await tokenService.RevokeFamilyAsync(familyId, userId);
return Results.Ok(new { message = "Session terminated." });
}).RequireAuthorization();
8. Захист від додаткових векторів атак
HttpOnly Cookie vs Bearer у body
| Спосіб зберігання | XSS вразливість | CSRF вразливість | Рекомендований |
|---|---|---|---|
localStorage | ✅ Вразливий | ❌ Захищений | ❌ |
sessionStorage | ✅ Вразливий | ❌ Захищений | ❌ |
HttpOnly Cookie | ❌ Захищений | ✅ Вразливий | ✅ + CSRF token |
| Memory (JS var) | ❌ Захищений | ❌ Захищений | ✅ (тільки SPA) |
// Для Cookie-based refresh tokens — захист від CSRF:
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-Token"; // SPA передає у заголовку
});
app.MapPost("/auth/refresh", [ValidateAntiForgeryToken] async (/* ... */) =>
{
// ...
});
Binding refresh token до пристрою
Для максимальної безпеки — прив'язати token до конкретного Device:
// При логіні — генеруємо Device ID
var deviceId = Convert.ToBase64String(
RandomNumberGenerator.GetBytes(32));
// Зберігаємо у cookie (окремо від refresh token)
ctx.Response.Cookies.Append("device_id", deviceId, new CookieOptions
{
HttpOnly = true, Secure = true, MaxAge = TimeSpan.FromDays(365)
});
// При refresh — перевіряємо співпадіння Device ID
var requestDeviceId = ctx.Request.Cookies["device_id"];
if (token.DeviceId != requestDeviceId)
{
// Device змінився — або крадіжка, або новий пристрій
// Можна вимагати повторного логіну
}
9. Практичні завдання
Рівень 1: Базовий
Реалізуйте основний flow:
POST /auth/login→ access_token (15 хв) + refresh_token (30 днів у cookie)POST /auth/refresh→ новий access_token + новий refresh_token- Переконайтеся: старий refresh token після rotation повертає помилку
- Перевірте у БД:
UsedAtзаповнений після першого refresh POST /auth/logout→ cookie видаляється, tokenIsRevoked = true
Перевірте захист від крадіжки:
- Виконайте логін → отримайте refresh_token_A
- Виконайте refresh → отримайте refresh_token_B (token_A стає UsedAt)
- Знову надішліть token_A (повторне використання старого токена)
- Очікуваний результат: 401 + ВСІ токени сім'ї анульовані
- Спробуйте token_B після анулювання → теж 401
Рівень 2: Проєктування
Додайте sliding expiration:
ExpiresAt= +30 днів при кожному rotationAbsoluteExpiresAt= +180 днів від логіну (не оновлюється)- Тест: refresh кожні 25 днів протягом 180 днів — токен активний
- Тест: після 180 днів з першого логіну — токен прострочений навіть якщо refresh
Реалізуйте управління сесіями:
- Зберігайте
DeviceInfo(User-Agent parsing: OS + browser) GET /auth/sessions→ список сесій з device info, IP, дата останнього refreshDELETE /auth/sessions/{familyId}→ завершення конкретної сесії- Позначте поточну сесію (за
device_idcookie абоfamily_idз поточного токена) DELETE /auth/sessions(без id) → logout all
Рівень 3: Архітектура
Реалізуйте production-ready token system:
- Перемістіть токени з PostgreSQL у Redis (швидший lookup, TTL-based expiry)
- При detection reuse — immediate push notification user (email або websocket)
- Anomaly detection: refresh з нового географічного регіону — підтвердження
- Token binding до device fingerprint (canvas fingerprint або UA hash)
- Аудит-лог всіх refresh операцій у окрему таблицю (10M+ записів тест)
10. Резюме
Rotation: старий → новий
/auth/refresh: старий токен помічається як UsedAt, видається новий. Зловмисник не може використати вкрадений token повторно.Token Family Detection
HttpOnly Cookie = захист від XSS
HttpOnly; Secure; SameSite=Strict cookie недоступний для JS. XSS не може вкрасти. + CSRF token для захисту від CSRF.Family = пристрій/сесія
DELETE /sessions/{familyId} = logout з конкретного пристрою.Далі: наступна стаття — Certificate Authentication та mTLS — аутентифікація за допомогою клієнтських TLS-сертифікатів: ідеальне рішення для сервіс-до-сервісної комунікації.
Rate Limiting та Throttling в ASP.NET Core
Глибоке занурення в Rate Limiting: навіщо він потрібен, як працюють чотири алгоритми, per-user та per-IP стратегії, Redis для розподілених систем та правильне проєктування відміни запитів.
Certificate Authentication та mTLS в ASP.NET Core
Аутентифікація через клієнтські TLS-сертифікати, Mutual TLS (mTLS), налаштування Kestrel та NGINX, ICertificateValidationService, прив