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

В связи с ростом количества API-интерфейсов и их глобального потребления в наши дни, безопасность API чрезвычайно важна. Аутентификация JWT — это стандартный способ защиты API-интерфейсов. Он позволяет проверять данные, передаваемые по сети между API-интерфейсами и клиентами, которые используют API-интерфейсы.

Веб-токены JSON (широко известные как JWT) — это открытый стандарт для передачи данных между клиентом и сервером, позволяющий безопасно передавать данные между сервером и потребителями. В этой статье рассказывается о том, как можно использовать JWT для защиты API.

Что понадобится

Если вы собираетесь работать с примерами кода, обсуждаемыми в этой статье, в вашей системе должно быть установлено следующее:

  • Visual Studio 2019 (более ранняя версия также будет работать, но предпочтительнее Visual Studio 2019)
  • .NET 5.0
  • Среда выполнения ASP.NET 5.0

Вы можете скачать Visual Studio 2019 отсюда: https://visualstudio.microsoft.com/downloads/. Вы можете загрузить среду выполнения .NET 5.0 и ASP.NET 5.0 отсюда: https://dotnet.microsoft.com/download/dotnet/5.0.

Что такое веб-токены JSON (JWT)?

JSON Web Token — это открытый стандарт (RFC 7519), который определяет безопасный, компактный и автономный защищенный способ передачи информации между отправителем и получателем через URL-адрес, параметр POST, или внутри заголовка HTTP. Следует отметить, что информация, которая должна безопасно передаваться между двумя сторонами, представлена в формате JSON и криптографически подписана для проверки ее подлинности. JWT обычно используется для реализации аутентификации и авторизации в веб-приложениях. Поскольку JWT является стандартом, все JWT являются токенами, но не наоборот. Вы можете работать с веб-токенами JSON в .NET, Python, Node.js, Java, PHP, Ruby, Go, JavaScript и т. д..

На рисунке 1 показано, как работает типичная аутентификация JWT.

Рисунок 1: Аутентификация JWT в действии

Рисунок 1: Аутентификация JWT в действии

JWT представлен как комбинация трех частей в кодировке base64url, объединенных символами точки (‘.’), И состоит из следующих трех разделов:

  • Заголовок
  • Полезная нагрузка
  • Подпись

Раздел заголовка (Header)

В этом разделе представлены метаданные о типе данных и алгоритме, который будет использоваться для шифрования данных, которые должны быть переданы. Заголовок JWT состоит из трех разделов — в их число входят: метаданные для токена, тип подписи и алгоритм шифрования. Он состоит из двух свойств, то есть «alg» и «typ». Хотя первый относится к используемому алгоритму криптографии, то есть к HS256 в данном случае, последний используется для указания типа токена, то есть JWT.

{
  "typ": "JWT",
  "alg": "HS256"
}

Полезная нагрузка (payload)

Полезная нагрузка представляет собой фактическую информацию в формате JSON, которая должна передаваться по сети. Приведенный ниже фрагмент кода иллюстрирует простую полезную нагрузку.

{
  "sub": "1234567890",
  "name": "Joydip Kanjilal",
  "admin": true,
  "jti": "cdafc246-109d-4ac9-9aa1-eb689fad9357",
  "iat": 1611497332,
  "exp": 1611500932
}

Payload обычно содержат утверждения (claims), идентификационную информацию пользователя,  разрешения и т. д. Вы можете использовать утверждения для передачи дополнительной информации. Они также называются утверждениями JWT (JWT claims) и бывают двух типов: зарезервированные и настраиваемые. Вот список зарезервированных требований:

  • iss: представляет эмитента токена.
  • sub: Это предмет токена.
  • aud: представляет аудиторию токена.
  • exp: используется для определения срока действия токена.
  • nbf: используется для указания времени, до которого токен не должен обрабатываться.
  • iat: это время, когда токен был выпущен.
  • jti: представляет собой уникальный идентификатор токена.

Вы также можете использовать настраиваемые утверждения, которые можно добавить к токену с помощью правила.

Подпись (Signature)

Подпись соответствует спецификации JSON Web Signature (JWS) и используется для проверки целостности данных  передаваемых по сети. Этот раздел содержит хэш заголовка, полезной нагрузки и секретного ключа, и используется для гарантии того, что сообщение не было изменено во время передачи. Окончательно подписанный токен создается в соответствии со спецификацией JSON Web Signature (JWS). Закодированный заголовок JWT, а также закодированная полезная нагрузка JWT объединяются, а затем подписываются с использованием надежного алгоритма шифрования, такого как HMAC SHA 256.

Приступим к реализации.

Сначала создайте новый проект ASP.NET Core MVC 5 в Visual Studio 2019. Создать проект в Visual Studio 2019 можно несколькими способами. Когда вы запустите Visual Studio 2019, вы увидите стартовое окно. Вы можете выбрать «Продолжить без кода», чтобы открыть главный экран IDE Visual Studio 2019. В меню главного экрана вы можете выбрать File> New> Project, чтобы открыть экран, показанный на рисунке 2 и 3.

Создание приложения

Рисунок 2: Выбор типа создаваемого проекта

Настройка среды выполнения

Рисунок 3: настройка проекта

Затем выполните последовательность шагов в Visual Studio 2019, чтобы создать новый проект ASP.NET Core MVC 5.

Устанавливаем необходимые  NuGet пакеты

  Следующим шагом является установка необходимых пакетов NuGet. Пакет NuGet представлен как файл с расширением .nupkg и состоит из скомпилированного кода ( DLL), других связанных файлов и манифеста, который предоставляет информацию, связанную с пакетом, такую как номер версии и т. д. Чтобы установить необходимые пакеты в свой проект, выполните следующие команды в консоли диспетчера пакетов NuGet:

  • dotnet add package Microsoft.AspNetCore.Authentication
  • dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Реализация JWT в ASP.NET Core 5 MVC

В этом разделе я рассмотрю, как можно реализовать аутентификацию JWT в приложении ASP.NET Core MVC 5. В этом примере вы будете использовать следующие классы и интерфейсы:

  • HomeController: Это класс контроллера, который содержит все методы действий.
  • ITokenService: Это интерфейс, который содержит объявление двух методов, то есть BuildToken и IsTokenValid. Первый используется для создания токена, а второй — для проверки действительности данного токена.
  • TokenService: Этот класс расширяет интерфейс ITokenService и реализует его методы.
  • IUserRepository: Этот интерфейс содержит объявление метода GetUser, который используется для получения экземпляра UserDTO на основе имени пользователя из экземпляра класса UserModel.
  • UserRepository: Класс UserRepository расширяет интерфейс IUserRepository и реализует метод GetUser. Он также содержит образцы данных, используемых приложением в виде списка класса UserDTO.

Создание классов модели

В этом приложении можно использовать две сущности: UserModel и классы UserDTO. Следующий фрагмент кода показывает, как выглядит класс UserModel:

public class UserModel
{
    [Required]
    public string UserName { get; set; }
    
    [Required]
    public string Password { get; set; }
}

Затем создайте класс с именем UserDTO со следующим содержимым:

public class UserDTO
{
    public string UserName { get; set; }
    public string Password { get; set; }
    public string Role { get; set; }
}

UserDTO представляет объект передачи пользовательских данных и содержит три строковых свойства: UserName, Password и Role. Вы будете использовать этот класс в нескольких местах вашего приложения.

Настройка JWT в файле AppSettings

Создайте раздел в файле appsettings.cs с именем Jwt со следующим содержимым :

"Jwt": {
    "Key": "Здесь вы должны указать свой секретный ключ, который используется для подписи и проверки токенов Jwt.",
    "Issuer": "zznob.ru"
}

Замените текст, упомянутый в пункте «Key» выше, фактическим ключом, который вы хотите использовать в качестве секрета. После добавления нового раздела ваш файл appsettings.json будет выглядеть так:

{
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft": "Warning",
            "Microsoft.Hosting.Lifetime": "Information"
        }
    },
    "Jwt": {
        "Key": "Суперсекретный ключ",
        "Issuer": zznob.ru
        "Audience": "http://localhost:5000/"
    },
    "AllowedHosts": "*"
}

Настраиваем аутентификацию с использованием Bearer и JWT

В методе ConfigureServices класса Startup я должен упомянуть, что буду использовать функцию AddAuthentication, а также JwtBearer, используя метод AddJwtBearer, как показано во фрагменте кода ниже.

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = Configuration["Jwt:Issuer"],
        ValidAudience = Configuration["Jwt:Issuer"],
        IssuerSigningKey = new
        SymmetricSecurityKey
        (Encoding.UTF8.GetBytes
        (Configuration["Jwt:Key"]))
    };
});

Следующий код можно использовать в методе ConfigureServices класса Startup для добавления временной службы типа IUserRepository и IITokenService соответственно.

services.AddTransient<IUserRepository, UserRepository>();
services.AddTransient<ITokenService, TokenService>();

После добавления этих экземпляров вы можете воспользоваться внедрением зависимостей в конструкторе класса HomeController для извлечения этих экземпляров из контейнера.

Полный код метода ConfigureServices приведен в листинге 1.

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddTransient<IUserRepository, UserRepository>();
    services.AddTransient<ITokenService, TokenService>();
    services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
    {        
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = Configuration["Jwt:Issuer"],
            ValidAudience = Configuration["Jwt:Issuer"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:Key"]))
        };
    });
}
Листинг1: Метод ConfigureServices

В этом примере вы воспользуетесь состоянием сеанса для хранения сгенерированного токена. Вы должны вызвать метод расширения UseSession в методе Configure класса Startup, чтобы включить состояние сеанса для вашего приложения. Приведенный ниже фрагмент кода показывает, как вы можете получить сгенерированный токен из сеанса, а затем добавить его в качестве токена-носителя в заголовок запроса.

app.Use(async (context, next) =>
{
    var token = context.Session.GetString("Token");
    if (!string.IsNullOrEmpty(token))
    {
        context.Request.Headers.Add("Authorization", "Bearer " + token);
    }
    await next();
});

В листинге 2 показан полный исходный код метода Configure — обратите внимание, как вы можете указать состояние сеанса, аутентификацию и маршрутизацию, которые будут использоваться.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }          
    else            
    {
        app.UseExceptionHandler("/Home/Error");
    }
    app.UseSession();
    app.Use(async (context, next) =>    
    {
        var token = context.Session.GetString("Token"); 
        if (!string.IsNullOrEmpty(token))  
        {
            context.Request.Headers.Add("Authorization", "Bearer " + token);
        }
        await next();
    });
    app.UseStaticFiles();
    app.UseRouting();
    app.UseAuthentication();
    app.UseEndpoints(endpoints =>  
    {
        endpoints.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); 
    });
}
Листинг 2: Метод Configure

Создание Views

Замените исходный код Index.cshtml кодом из листинга.

@model JWTAuth.Models.UserModel
@{
    ViewData["Title"] = "Index";
}

<hr />
<div class="row">  
    <div class="col-md-4">
        <form asp-action="Login"> 
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="UserName" class="control- label"></label> 
                <input asp-for="UserName" class="form-control" /> 
                <span asp-validation-for= "UserName" class="text-danger"></span>        
            </div>
            <div class="form-group">
                <label asp-for="Password" class="control-label"></label>    
                <input asp-for="Password" type="password" class="form-control"/>    
                <span asp-validation-for="Password" class="text-danger"></span> 
            </div>
            <div class="form-group">                
                <input type="submit" value="Login" class="btn btn-primary" />
            </div>
        </form>
    </div>

</div>

@section Scripts {@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}}

Затем создайте представление с именем MainWindow.cshtml и напишите в нем следующий код:

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>CodeMag JWT Demo</title>
</head>

<body>
  @{
      ViewBag.Title = "Demonstrating JWTs in ASP.NET Core MVC 5";
  }

  <p>You're logged in as: @User.Identity.Name</p>
  <p>@ViewBag.Message</p>
</body>
</html>

Наконец, создайте представление с именем Error.cshtml и напишите в нем следующий код:

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Error...</title>
</head>

<body>
  <p>@ViewBag.Message</p>
</body>
</html>

Создайте класс UserRepository

Класс репозитория — это реализация шаблона проектирования репозитория, который управляет доступом к данным. Приложение использует экземпляр репозитория для выполнения операций CRUD с базой данных. В этом примере HomeController взаимодействует с UserRepository для получения пользователя на основе имени пользователя и пароля.

Создайте файл с именем IUserRepository.cs со следующим содержимым внутри:

public interface IUserRepository
{
    UserDTO GetUser(UserModel userMode);
}

Класс UserRepository расширяет интерфейс IUserRepository и реализует метод GetUser, как показано в листинге . Он также создает список объектов UserDTO. Обратите внимание, что пароль здесь жестко запрограммирован для простоты.

public class UserRepository : IUserRepository    
{
    private readonly List<UserDTO> users = new List<UserDTO>();

    public UserRepository()
    {
        users.Add(new UserDTO 
        {
            UserName = "joydipkanjilal",    
            Password = "joydip123", 
            Role = "manager" 
        });    
        users.Add(new UserDTO 
        { 
            UserName = "michaelsanders",    
            Password = "michael321", 
            Role = "developer" 
        });
        users.Add(new UserDTO 
        { 
            UserName = "stephensmith",
            Password = "stephen123",
            Role = "tester" 
        });
        users.Add(new UserDTO 
        { 
            UserName = "rodpaddock",
            Password = "rod123", 
            Role = "admin" 
        });
        users.Add(new UserDTO
        { 
            UserName = "rexwills",
            Password = "rex321", 
            Role = "admin" 
        });
    }
    public UserDTO GetUser(UserModel userModel)
    {
        return users.Where(x => x.UserName.ToLower() == userModel.UserName.ToLower()               
            && x.Password == userModel.Password).FirstOrDefault(); 
    }
}

Создайте класс TokenService

Создайте интерфейс ITokenService со следующим содержимым:

public interface ITokenService
{
    string BuildToken(string key, string issuer, UserDTO user);
    bool ValidateToken(string key, string issuer, string audience, string token);
}

Класс TokenService расширяет интерфейс ITokenService и реализует его методы, как показано в листинге .

public class TokenService : ITokenService
{
    private const double EXPIRY_DURATION_MINUTES = 30;

    public string BuildToken(string key, string issuer, UserDTO user)
    {
        var claims = new[] {    
            new Claim(ClaimTypes.Name, user.UserName),
            new Claim(ClaimTypes.Role, user.Role),
            new Claim(ClaimTypes.NameIdentifier,
            Guid.NewGuid().ToString())
        };

        var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));        
        var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);           
        var tokenDescriptor = new JwtSecurityToken(issuer, issuer, claims, 
            expires: DateTime.Now.AddMinutes(EXPIRY_DURATION_MINUTES), signingCredentials: credentials);        
        return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);  
    }
    public bool IsTokenValid(string key, string issuer, string token)
    {
        var mySecret = Encoding.UTF8.GetBytes(key);           
        var mySecurityKey = new SymmetricSecurityKey(mySecret);
        var tokenHandler = new JwtSecurityTokenHandler(); 
        try 
        {
            tokenHandler.ValidateToken(token, 
            new TokenValidationParameters   
            {
                ValidateIssuerSigningKey = true,
                ValidateIssuer = true, 
                ValidateAudience = true,    
                ValidIssuer = issuer,
                ValidAudience = issuer, 
                IssuerSigningKey = mySecurityKey,
            }, out SecurityToken validatedToken);            
        }
        catch
        {
            return false;
        }
        return true;    
    }
}

Создайте класс HomeController

Наконец, мы подошли к классу контроллера. В классе HomeController вы воспользуетесь преимуществами внедрения зависимостей, чтобы иметь возможность использовать экземпляры классов Configuration, TokenService и UserRepository. Создайте следующие экземпляры только для чтения для каждого из трех интерфейсов, как показано ниже:

private readonly IConfiguration_config;
private readonly ITokenService_tokenService;
private readonly IUserRepository_userRepository;

Экземпляры ITokenService и IUserRepository создаются в методе ConfigureServices класса Startup, как показано в приведенном ниже фрагменте кода:

services.AddScoped<ITokenService, TokenService>();
services.AddScoped<IUserRepository, UserRepository>();

Интерфейс IConfiguration объявлен в классе Startup как свойство только для чтения, как показано ниже:

public IConfiguration Configuration { get; }

Вот как внедрение конструктора используется в классе HomeController для каждого из описанных ранее экземпляров.

public HomeController (IConfiguration config, ITokenService tokenService, IUserRepository userRepository)
{
    _tokenService = tokenService;
    _userRepository = userRepository;
    _config = config;
}

Полный исходный код класса HomeController приведен в листинге .

public class HomeController : Controller    
{
    private readonly IConfiguration _config;        
    private readonly IUserRepository _userRepository;       
    private readonly ITokenService _tokenService;  
    private string generatedToken = null;  
    
    public HomeController (IConfiguration config, ITokenService tokenService, IUserRepository userRepository)
    {
        _config = config;
        _tokenService = tokenService;
        _userRepository = userRepository;
    }
    
    public IActionResult Index()
    {
        return View(); 
    }

    [AllowAnonymous]    
    [Route("login")]    
    [HttpPost] 
    public IActionResult Login(UserModel userModel)
    {
        if (string.IsNullOrEmpty(userModel.UserName) || string.IsNullOrEmpty(userModel.Password))  
        {
            return (RedirectToAction("Error"));
        }
        IActionResult response = Unauthorized();        
        var validUser = GetUser(userModel);
    
        if (validUser != null)  
        {
            generatedToken = _tokenService.BuildToken(_config["Jwt:Key"].ToString(), _config["Jwt:Issuer"].ToString(), validUser);
            if (generatedToken != null) 
            {
                HttpContext.Session.SetString("Token", generatedToken);
                return RedirectToAction("MainWindow"); 
            }
            else
            {
                return (RedirectToAction("Error")); 
            }
        }
        else
        {
            return (RedirectToAction("Error"));
        }
    }

    private UserDTO GetUser(UserModel userModel)        
    {
        // Write your code here to authenticate the user     
        return _userRepository.GetUser(userModel);
    }

    [Authorize]
    [Route("mainwindow")]
    [HttpGet]
    public IActionResult MainWindow() 
    {
        string token = HttpContext.Session.GetString("Token");
        if (token == null)
        {
            return (RedirectToAction("Index"));
        }
        if (!_tokenService.IsTokenValid(_config["Jwt:Key"].ToString(), _config["Jwt:Issuer"].ToString(), token)) 
        {
            return (RedirectToAction("Index")); 
        }
        ViewBag.Message = BuildMessage(token, 50);      
        return View(); 
    }

    public IActionResult Error()
    {
        ViewBag.Message = "An error occured...";
        return View();  
    }

    private string BuildMessage(string stringToSplit, int chunkSize)
    {
        var data = Enumerable.Range(0, stringToSplit.Length / chunkSize).Select(i => stringToSplit.Substring (i * chunkSize, chunkSize));
        string result = "The generated token is:";
        foreach (string str in data)            
        {
            result += Environment.NewLine + str;
        }
        return result;      
        }
    }    

Метод BuildMessage используется для разделения сгенерированного токена на несколько строк. Метод GetUser класса HomeController вызывает метод GetUser класса UserRepository для получения экземпляра класса UserDTO на основе учетных данных пользователя, введенных на экране входа в систему.

Запустить приложение

Теперь запустите приложение, нажав Ctrl + F5 или просто F5. После того, как вы укажете учетные данные пользователя и нажмете «Войти», вы будете перенаправлены на другую веб-страницу, на которой отображается имя вошедшего в систему пользователя и сгенерированный токен

Резюме

JSON Web Token (JWT) — это открытый стандарт (RFC 7519), который определяет, как вы можете безопасно передавать информацию между двумя сторонами. Вы должны использовать SSL / TLS вместе с веб-токенами JSON (JWT) для борьбы с атаками типа «злоумышленник в середине». В большинстве случаев этого должно быть достаточно для шифрования полезной нагрузки перед ее передачей по сети. Вы также можете использовать JWT в качестве дополнительного уровня безопасности.

.