Как осуществляется генерация и валидация JWT-токенов для аутентификации в .NET?

Ответ

JWT (JSON Web Token) — стандартный способ передачи claims между сторонами в виде подписанного JSON-объекта. В .NET для работы с JWT используется пакет System.IdentityModel.Tokens.Jwt.

1. Генерация Access Token Токен создается на сервере аутентификации после успешной проверки учетных данных.

using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

public string GenerateJwtToken(string userId, string email, List<string> roles)
{
    // 1. Подготовка секретного ключа (в продакшене храните в безопасном месте, например, в Azure Key Vault)
    var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Secret"]));
    var signingCredentials = new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256);

    // 2. Формирование claims (утверждений о пользователе)
    var claims = new List<Claim>
    {
        new Claim(JwtRegisteredClaimNames.Sub, userId), // Subject (идентификатор пользователя)
        new Claim(JwtRegisteredClaimNames.Email, email),
        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) // Unique token ID
    };
    // Добавление ролей как отдельных claims (стандарт для ASP.NET Core)
    claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

    // 3. Создание самого токена
    var token = new JwtSecurityToken(
        issuer: _configuration["Jwt:Issuer"],        // Кто выдал
        audience: _configuration["Jwt:Audience"],    // Для кого предназначен
        claims: claims,
        expires: DateTime.UtcNow.AddMinutes(15),     // Короткое время жизни Access Token
        signingCredentials: signingCredentials
    );

    // 4. Кодирование токена в строку
    return new JwtSecurityTokenHandler().WriteToken(token);
}

2. Валидация токена в API В ASP.NET Core валидация настраивается централизованно в Program.cs или Startup.cs.

// Конфигурация аутентификации
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],

            ValidateAudience = true,
            ValidAudience = builder.Configuration["Jwt:Audience"],

            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"])
            ),

            ValidateLifetime = true, // Проверять срок действия
            ClockSkew = TimeSpan.Zero // Не давать "форы" по времени (опционально)
        };
        // Для Bearer-токенов из заголовка Authorization этого достаточно.
        // Для токенов из кук или query-строки настройте options.Events.
    });

// Затем используйте атрибут [Authorize] на контроллерах или методах.

3. Refresh Token Механизм Access Token короткоживущий, Refresh Token — долгоживущий (дни, недели), хранится безопасно (в HttpOnly куке или БД) и используется для получения новой пары токенов.

// Генерация Refresh Token (случайная строка, сохраненная в БД с привязкой к userId)
public string GenerateRefreshToken()
{
    var randomNumber = new byte[32];
    using var rng = RandomNumberGenerator.Create();
    rng.GetBytes(randomNumber);
    return Convert.ToBase64String(randomNumber);
}

// Эндпоинт для обновления токенов
[HttpPost("refresh-token")]
public async Task<IActionResult> RefreshToken(RefreshTokenRequest request)
{
    // 1. Валидируем старый Access Token (даже истекший) чтобы извлечь userId
    // 2. Проверяем, что переданный Refresh Token существует и активен для этого userId в БД
    // 3. Если все ок, генерируем новую пару Access/Refresh токенов
    // 4. Старый Refresh Token инвалидируем (удаляем или помечаем использованным)
    // 5. Возвращаем новые токены клиенту
}

4. Критически важные практики безопасности:

  • Никогда не храните секретный ключ (Secret) в коде. Используйте менеджеры секретов (Azure Key Vault, HashiCorp Vault) или переменные окружения.
  • Используйте HTTPS (TLS) всегда. JWT передается открытым текстом.
  • Храните Refresh Tokens безопасно: HttpOnly, Secure, SameSite=Strict куки — лучший вариант для веб-приложений.
  • Реализуйте отзыв токенов (token revocation): Вести черный список отозванных токенов (по jti) или инвалидировать все токены пользователя при смене пароля.
  • Ограничивайте claims: Не помещайте в токен чувствительные данные (пароли).

Ответ 18+ 🔞

Слушай, вот тебе про JWT, но без этой заумной сухой документации. Представь, что это такой пропуск в ночной клуб, только для твоего API.

1. Штампуем пропуск (Access Token) После того как пользователь доказал, что он не левый (логин-пароль), надо ему выдать эту бумажку.

using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

public string СгенеритьТокен(string userId, string email, List<string> roles)
{
    // 1. Секретный ключ. Это как печать, которой штампуем.
    // НАХУЙ НЕ ХРАНИ ЭТО В КОДЕ! Вынеси в конфиг, в переменные окружения, в сейф — куда угодно, только не тут.
    var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Secret"]));
    var signingCredentials = new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256);

    // 2. Что пишем в пропуске (claims). Типа "Имя: Вася, Должность: Гуру".
    var claims = new List<Claim>
    {
        new Claim(JwtRegisteredClaimNames.Sub, userId), // Кто владелец
        new Claim(JwtRegisteredClaimNames.Email, email),
        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) // Уникальный номер пропуска, чтобы потом отозвать, если что
    };
    // Добавляем роли отдельными строчками
    claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

    // 3. Собираем сам пропуск
    var token = new JwtSecurityToken(
        issuer: _configuration["Jwt:Issuer"],        // Кто выдал (название твоего приложения)
        audience: _configuration["Jwt:Audience"],    // Для кого (обычно тоже твоё API)
        claims: claims,
        expires: DateTime.UtcNow.AddMinutes(15),     // Живёт 15 минут! Коротко — чтобы, если украли, недолго радовались.
        signingCredentials: signingCredentials       // Подписываем нашей печатью
    );

    // 4. Превращаем в строку, которую можно в заголовок воткнуть
    return new JwtSecurityTokenHandler().WriteToken(token);
}

2. Проверка пропуска на входе (в API) В ASP.NET Core это настраивается один раз, а потом работает автоматом. Ставишь швейцара.

// В Program.cs или Startup.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true, // Проверяем, что мы сами выдали
            ValidIssuer = builder.Configuration["Jwt:Issuer"],

            ValidateAudience = true, // Проверяем, что он для нашего API
            ValidAudience = builder.Configuration["Jwt:Audience"],

            ValidateIssuerSigningKey = true, // Самое главное: проверяем подпись (печать)
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"])
            ),

            ValidateLifetime = true, // Смотрим, не просрочен ли
            ClockSkew = TimeSpan.Zero // Без форы. Просрочился — нахуй, в баню.
        };
        // Всё, теперь любой запрос с заголовком `Authorization: Bearer <токен>` будет проверяться.
    });

// А на контроллер или метод вешаешь [Authorize] — и туда только со valid пропуском.

3. Механизм Refresh Token'ов (чтобы не логиниться каждые 15 минут) Access Token — это временный пропуск. Refresh Token — это годовая подписка, по которой тебе выдают новый временный пропуск. Хранить её надо как зеницу ока.

// Генерация Refresh Token'а — просто случайная длинная строка
public string СгенеритьРефрешТокен()
{
    var randomNumber = new byte[32];
    using var rng = RandomNumberGenerator.Create();
    rng.GetBytes(randomNumber);
    return Convert.ToBase64String(randomNumber);
    // И ЭТУ СТРОКУ НАДО СОХРАНИТЬ В БАЗУ, привязав к userId! Иначе как проверишь потом?
}

// Эндпоинт, где меняют старый Access Token на новый
[HttpPost("refresh-token")]
public async Task<IActionResult> ОбновитьТокен(RefreshTokenRequest request)
{
    // 1. Расшифровываем старый (уже протухший) Access Token, чтобы вытащить оттуда userId (внутри ещё есть инфа).
    // 2. Ищем в базе: есть ли у этого userId такой Refresh Token, который нам прислали, и не отозван ли он.
    // 3. Если всё чисто — ГЕНЕРИМ НОВУЮ ПАРУ: свежий Access Token и, опционально, новый Refresh Token.
    // 4. Старый Refresh Token из базы УДАЛЯЕМ или помечаем использованным. Иначе им можно будет пользоваться вечно — это пиздец какой дырой станет.
    // 5. Отдаём новую пару клиенту.
}

4. Безопасность, про которую все забывают, а потом плачут:

  • Секретный ключ (Secret) — это твоя королевская печать. Если её засветят — любой сможет штамповать левые пропуска. НИ В КОДЕ, НИ В ГИТЕ. Только в секретных хранилищах или переменных окружения на сервере.
  • HTTPS (TLS) — ОБЯЗАТЕЛЬНО. JWT летает открытым текстом. Без HTTPS его снимет любой, кто рядом с WiFi сидит.
  • Refresh Token храни ТОЛЬКО в HttpOnly, Secure, SameSite=Strict куках (для веб-приложений). Так к нему со стороны JS не подобраться. Если хранишь в БД — шифруй нахер.
  • Реализуй отзыв токенов. Пользователь сменил пароль? — Все его Refresh Token'ы в мусорку. Заподозрил неладное? — Добавляй jti просроченного Access Token'а в чёрный список (хотя бы на время его жизни).
  • Не пихай в токен лишнего. Ни паролей, ни номеров карт, ни размера трусов. Только минимум для идентификации. Он же не шифруется, а только подписывается — любой может его прочитать.