Auth

Refresh Token Rotation в ASP.NET Core

Повна реалізація Refresh Token Rotation: JWT access tokens, rotating refresh tokens, сімейство токенів (token family), sliding expiration, захист від крадіжки токенів та відкликання.

Refresh Token Rotation в ASP.NET Core

JWT Access Token дійсний 15 хвилин — щоб не змушувати користувача логінитись кожні 15 хвилин, Refresh Token дозволяє отримати новий access token без повторного логіну. Але якщо refresh token вкрадуть — зловмисник матиме довгостроковий доступ. Refresh Token Rotation вирішує це: кожен refresh породжує нову пару токенів, старий одразу інвалідується.

1. Проблема довгоживучих токенів

Чому JWT не відкликати?

JWT — stateless. Сервер не зберігає список виданих токенів — він лише перевіряє підпис та термін дії. Тому неможливо «відкликати» окремий JWT без централізованого blacklist.

Компроміс Access Token:
├── Термін дії: 15 хвилин
├── Зловмисник використовує: макс 15 хвилин
└── Збиток: обмежений часом

Компроміс Refresh Token:
├── Термін дії: 30 днів
├── Зловмисник може оновлювати нескінченно
└── Збиток: довгостроковий злам аккаунту

Solution: Rotation + Detection

Rotation: при кожному refresh → старий token видаляється, видається новий. Зловмисник не може використати вкрадений token двічі.

Detection: якщо вкрадений token використали раніше за легітимного клієнта → обидва виявляють спробу повторного використання → анулюємо всю сім'ю токенів.


2. Модель даних

Models/RefreshToken.cs
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!;
}
Data/AppDbContext.cs — RefreshTokens
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: генерація та валідація

Services/TokenService.cs
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 — видача пари токенів

POST /auth/login — з Refresh Token
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 — ротація пари токенів

POST /auth/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 — відкликання токена

POST /auth/logout — 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();
POST /auth/logout-all — вийти зі ВСІХ пристроїв
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.

GenerateRefreshTokenAsync з Sliding Expiration
// У 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 буде зростати. Потрібна регулярна очистка:

Services/TokenCleanupService.cs — Background Service
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);
    }
}
Program.cs — реєстрація cleanup
builder.Services.AddHostedService<TokenCleanupService>();

7. Сесії та аудит

GET /auth/sessions — активні сесії
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. Захист від додаткових векторів атак

Спосіб зберіганняXSS вразливістьCSRF вразливістьРекомендований
localStorage✅ Вразливий❌ Захищений
sessionStorage✅ Вразливий❌ Захищений
HttpOnly Cookie❌ Захищений✅ Вразливий✅ + CSRF token
Memory (JS var)❌ Захищений❌ Захищений✅ (тільки SPA)
Захист від CSRF при Cookie зберіганні
// Для 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 Fingerprinting
// При логіні — генеруємо 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: Базовий

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

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


10. Резюме

Rotation: старий → новий

При кожному /auth/refresh: старий токен помічається як UsedAt, видається новий. Зловмисник не може використати вкрадений token повторно.

Token Family Detection

Якщо вже використаний токен надіслано знову → анулюємо ВСЮ сім'ю. Один скомпрометований token = logout з пристрою і зловмисника, і жертви.

HttpOnly Cookie = захист від XSS

Refresh token у HttpOnly; Secure; SameSite=Strict cookie недоступний для JS. XSS не може вкрасти. + CSRF token для захисту від CSRF.

Family = пристрій/сесія

Кожен логін = нова сім'я. Всі rotation в межах однієї сім'ї. DELETE /sessions/{familyId} = logout з конкретного пристрою.

Далі: наступна стаття — Certificate Authentication та mTLS — аутентифікація за допомогою клієнтських TLS-сертифікатів: ідеальне рішення для сервіс-до-сервісної комунікації.

Copyright © 2026