Minimal APIs - JWT Token e autenticação JWT


  Neste artigo vamos recordar como implementar a autenticação JWT usando minimal APIs.

Eu já escrevi diversos artigos sobre a autenticação JWT e a geração do token JWT, e hoje retorno ao assunto para tratar do tema agora usando as minimal APIs.

Para saber mais sobre o que é o token JWT e como ele funciona consulte as referências no final do artigo.

Este artigo é uma adaptação do artigo original : C# JWT Authentication .NET 6

Configurando a autenticação e autorização

Vamos criar um novo projeto usando o .NET 8 e o template ASP.NET Core Web API com o nome MinApiJwt;

A seguir vamos instalar os pacotes nugets :

Microsoft.AspNetCore.Authentication.JwtBearer
System.IdentityModel.Tokens.Jwt
Ao final o arquivo de projeto deverá possuir as seguintes referências:
<Project Sdk="Microsoft.NET.Sdk.Web">

<
PropertyGroup>
  <
TargetFramework>net8.0</TargetFramework>
  <
Nullable>enable</Nullable>
  <
ImplicitUsings>enable</ImplicitUsings>
  <
InvariantGlobalization>true</InvariantGlobalization>
</
PropertyGroup>

<ItemGroup>
  <
PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
  <
PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
  <
PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
  <
PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.0.3" />
</
ItemGroup>

</Project>

Agora vamos definir no arquivo appsettings.json uma seção JwtOptions onde vamos definir os valores para a Audience, Issuer e para a chave privada que iremos usar para gerar o token JWT :

{
 
"JwtOptions": {
   
"Issuer": "https://localhost:7004",
   
"Audience": "https://localhost:7004",
   
"PrivateKey": "kw#zopfg&ncmj287@09dopcw#1ztspk7x2b&",
   
"ExpireSeconds": 3600
},
"Logging": {
  
"LogLevel": {
    
"Default": "Information",
    
"Microsoft.AspNetCore": "Warning"
   }
 },
 
"AllowedHosts": "*"
}

Vamos criar uma pasta Token no projeto e nesta pasta criar um record para conter esta configuração e poder usá-la na aplicação :

internal record JwtOptions(
   
string Issuer,string Audience,string PrivateKey,int ExpireSeconds
);

Com isso podemos ler a configuração e mapeá-la para JwtOptions configurando o serviço com o seguinte código:

var builder = WebApplication.CreateBuilder(args);

var jwtOptions = builder.Configuration
                  .GetSection(
"JwtOptions")
                  .Get<JwtOptions>();

builder.Services.Configure<JwtOptions>
            (builder.Configuration.GetSection(
"JwtOptions"));
...

Agora podemos definir a autorização JwtBearer e a autenticação :

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
   .AddJwtBearer(opts =>
   {
      byte[] signingKeyBytes = Encoding.UTF8
             .GetBytes(jwtOptions.PrivateKey);

      opts.TokenValidationParameters =
new TokenValidationParameters
      {
        ValidateIssuer =
true,
        ValidateAudience =
true,
        ValidateLifetime =
true,
        ValidateIssuerSigningKey =
true,
        ValidIssuer = jwtOptions.Issuer,
        ValidAudience = jwtOptions.Audience,
        IssuerSigningKey =
new SymmetricSecurityKey(signingKeyBytes)|
      };
   });
builder.Services.AddAuthorization();
...

Gerando o Token JWT

Para gerar o token vamos criar uma classe contendo dois métodos : Connect e CreateAccessToken

Na pasta Token vamos incluir a classe TokenEndpoint com seguinte código:

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

namespace MinApiJwt.Token;

public class TokenEndpoint
{
    public static async Task<IResult> Connect(HttpContext ctx, JwtOptions jwtOptions)
    {
        if (ctx.Request.ContentType != "application/x-www-form-urlencoded")
            return Results.BadRequest(new { Error = "Invalid Request" });

        var formCollection = await ctx.Request.ReadFormAsync();

        // obtem informação do formulário
        if (formCollection.TryGetValue("grant_type", out var grantType) == false)
            return Results.BadRequest(new { Error = "Invalid Request" });

        if (formCollection.TryGetValue("username", out var userName) == false)
            return Results.BadRequest(new { Error = "Invalid Request" });

        if (formCollection.TryGetValue("password", out var password) == false)
            return Results.BadRequest(new { Error = "Invalid Request" });

        //cria o token de acesso(jwt token)
        var tokenExpiration = TimeSpan.FromSeconds(jwtOptions.ExpireSeconds);

        var accessToken = TokenEndpoint.CreateAccessToken(
                          jwtOptions, userName, TimeSpan.FromMinutes(60),
                          new[] { "read_todo", "create_todo" });

        //retorna o response json com o token
        return Results.Ok(new
        {
            access_token = accessToken,
            expiration = (int)tokenExpiration.TotalSeconds,
            type = "bearer"
        });
    }

    static string CreateAccessToken(
                    JwtOptions jwtOptions,
                    string username,
                    TimeSpan expiration,
                    string[] permissions)
    {
        var keyBytes = Encoding.UTF8.GetBytes(jwtOptions.PrivateKey);
        var symmetricKey = new SymmetricSecurityKey(keyBytes);

        var signingCredentials = new SigningCredentials(
            symmetricKey,
            SecurityAlgorithms.HmacSha256);

        var claims = new List<Claim>()
        {
            new Claim("sub", username),
            new Claim("name", username),
            new Claim("aud", jwtOptions.Audience)
        };

        var roleClaims = permissions.Select(x => new Claim("role", x));

        claims.AddRange(roleClaims);

        var token = new JwtSecurityToken(
            issuer: jwtOptions.Issuer,
            audience: jwtOptions.Audience,
            claims: claims,
            expires: DateTime.Now.Add(expiration),
            signingCredentials: signingCredentials);

        var rawToken = new JwtSecurityTokenHandler().WriteToken(token);
        return rawToken;
    }
}

Temos dois métodos estáticos um para realizar a conexão e obter os dados do formulário e outro para gerar o Token JWT.

O método CreateAccessToken gerará o token com as declarações:

- sub: identificador exclusivo para o usuário final
- name: nome completo do usuário final
- aud: Público(s) ao qual este Token se destina.
- role: perfis do usuário


O método Connect lê o formulário e valida cada propriedade antes de gerar o token de acesso. Depois que tudo estiver definido, chamamos o método CreateAccessToken.

A partir deste passo, implementaremos o endpoint para criar os JWT Tokens (Access Tokens).

A seguir temos o código da classe Program contendo os endpoints:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using MinApiJwt.Token;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var jwtOptions = builder.Configuration
                        .GetSection("JwtOptions")
                        .Get<JwtOptions>();
builder.Services.Configure<JwtOptions>
    (builder.Configuration.GetSection("JwtOptions"));
// Add services to the container.
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(opts =>
    {
        //convert the string signing key to byte array
        byte[] signingKeyBytes = Encoding.UTF8
            .GetBytes(jwtOptions.PrivateKey);
        opts.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwtOptions.Issuer,
            ValidAudience = jwtOptions.Audience,
            IssuerSigningKey = new SymmetricSecurityKey(signingKeyBytes)
        };
    });
builder.Services.AddAuthorization();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
app.UseHttpsRedirection();
var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/", () => $"Acessado em {DateTime.UtcNow.ToShortDateString()}")
    .WithOpenApi();
app.MapGet("/weatherforecast", () =>
{
    var forecast = Enumerable.Range(1, 5).Select(index =>
        new WeatherForecast
        (
            DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            Random.Shared.Next(-20, 55),
            summaries[Random.Shared.Next(summaries.Length)]
        ))
        .ToArray();
    return forecast;
})
.WithName("GetWeatherForecast")
.RequireAuthorization()
.WithOpenApi();
app.MapGet("/public", () => "Endpoint público !!!!")
    .AllowAnonymous()
    .WithOpenApi();
// as rotas privadas requerem um request autorizado
app.MapGet("/private", () => "Endpoint privado !!!!")
    .RequireAuthorization()
    .WithOpenApi();
// trata o endpoint do request token
app.MapPost("/tokens/connect", (HttpContext ctx, JwtOptions jwtOptions)
                                => TokenEndpoint.Connect(ctx, jwtOptions))
                                  .WithOpenApi();
app.Run();
internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

Para testar podemos usar o seguinte esqueleto do endpoint neste formato:

POST /conect/token HTTP/1.1
Content-Type:: application/x-www-form-urlencoded

grant_type=password&username=johndoe&password=A3ddj3wr
 

E a resposta deveria ser:

HTTP/1.1 200 OK
Connection: close
Content-Type: application/json; charset=utf-8
Date: Thu, 27 Apr 2023 17:35:45 GMT
Server: Kestrel
Transfer-Encoding: chunked

{
  "access_token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJz......",
  "expiration": 3600,
  "type": "bearer"
}

Abaixo temos os request no arquivo MinApiJwt.http :

@MinApiJwt_HostAddress = https://localhost:7107

GET {{MinApiJwt_HostAddress}}/weatherforecast/
Accept
: application/json

###

POST https://localhost:7107/tokens/connect HTTP/1.1
Content-Type
: application/x-www-form-urlencoded

grant_type=password&username=johndoe&password=A3ddj3wr

###

GET https://localhost:7107/public
Accept
: application/json

###

GET https://localhost:7107/private
Accept
: application/json

###

GET https://localhost:7107/private
Authorization
: Bearer eyJhbGciOiJIUzUxMiIs...
 

Obs: A porta deve ser alterada para a usada no seu ambiente.

E estamos conversados...

"E no último dia, o grande dia da festa, Jesus pôs-se em pé, e clamou, dizendo: Se alguém tem sede, venha a mim, e beba. Quem crê em mim, como diz a Escritura, rios de água viva correrão do seu ventre."
João 7:37,38

Referências:


José Carlos Macoratti