DEV Community

Tobias Mesquita for Quasar Framework Brasil

Posted on

QPANC - Parte 7 - ASP.NET - Autenticação e Autorização

QPANC são as iniciais de Quasar PostgreSQL ASP NET Core.

12 Autenticação usando tokens JWT

Agora que temos o Swagger configurado, precisamos configurar um aspecto importante da aplicação, a geração dos tokens que serão utilizados para autenticação e autorização.

Como de costume, iremos precisar instalar alguns pacotes, iremos começar pelo System.IdentityModel.Tokens.Jwt e Microsoft.AspNetCore.Authentication.JwtBearer no projeto QPANC.Api

cd QPANC.Api
dotnet add package System.IdentityModel.Tokens.Jwt
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

O próximo passo, é criar os serviços necessários para expor as configurações de geração dos Tokens.

QPANC.Services.Abstract/IJwtBearer.cs

namespace QPANC.Services.Abstract
{
    public interface IJwtBearer
    {
        byte[] IssuerSigningKey { get; }
        byte[] TokenDecryptionKey { get; }
        string ValidIssuer { get; }
        string ValidAudience { get; }
    }
}

QPANC.Services/JwtBearer.cs

using Microsoft.Extensions.Configuration;
using QPANC.Services.Abstract;
using System;
using IConfiguration = QPANC.Services.Abstract.IConfiguration;

namespace QPANC.Services
{
    public class JwtBearer : IJwtBearer
    {
        private IConfiguration _configuration;

        public JwtBearer(IConfiguration configuration)
        {
            this._configuration = configuration;
        }

        public string ValidIssuer { get { return this._configuration.Root.GetValue<string>("JWTBEARER_VALIDISSUER"); } }

        public string ValidAudience { get { return this._configuration.Root.GetValue<string>("JWTBEARER_VALIDAUDIENCE"); } }

        public byte[] IssuerSigningKey { get { return this.Base64AsBinary("JWTBEARER_ISSUERSIGNINGKEY"); } }

        public byte[] TokenDecryptionKey { get { return this.Base64AsBinary("JWTBEARER_TOKENDECRYPTIONKEY"); } }

        private byte[] Base64AsBinary(string key)
        {
            var base64 = _configuration.Root.GetValue<string>(key);
            if (string.IsNullOrWhiteSpace(base64))
                return new byte[] { };
            return Convert.FromBase64String(base64);
        }
    }
}

E claro, não podemos deixar de adicionar este serviço no nosso agregador de configurações, o IAppSettings

QPANC.Services.Abstract/IAppSettings.cs

namespace QPANC.Services.Abstract
{
    public interface IAppSettings
    {
        IConnectionStrings ConnectionString { get; }
        IJwtBearer JwtBearer { get; }
    }
}

QPANC.Services/AppSettings.cs

using QPANC.Services.Abstract;

namespace QPANC.Services
{
    public class AppSettings : IAppSettings
    {
        public IConnectionStrings ConnectionString { get; }
        public IJwtBearer JwtBearer { get; }

        public AppSettings(IConnectionStrings connectionStrings, IJwtBearer jwtBetter)
        {
            this.ConnectionString = connectionStrings;
            this.JwtBearer = jwtBetter;
        }
    }
}

e o respectivo registro no QPANC.Api/Extensions/ServiceCollectionExtensions.cs:

using Microsoft.Extensions.DependencyInjection;
using QPANC.Services;
using QPANC.Services.Abstract;

namespace QPANC.Api.Extensions
{
    public static class ServiceCollectionExtensions
    {
        public static void AddAppSettings(this IServiceCollection services)
        {
            services.AddSingleton<IConfiguration, Configuration>();
            services.AddSingleton<IConnectionStrings, ConnectionStrings>();
            services.AddSingleton<IJwtBearer, JwtBearer>();
            services.AddSingleton<IAppSettings, AppSettings>();
        }
    }
}

Agora que conseguimos acessar as chaves, teremos criar as chaves propriamente dito, para tal, é recomendado que se gere 96 bytes de forma aleatória, iremos usar 64 destas bytes para criar a chave para assinar o token e 32 para a chave para descriptografar o token.:

Segue uma sugestão de implementação para geração destas chaves:

namespace QPANC.Api
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var random = RandomNumberGenerator.Create();
            var binarySigningKey = new byte[64];
            var binaryTokenDecryptionKey = new byte[32];
            random.GetBytes(binarySigningKey);
            random.GetBytes(binaryTokenDecryptionKey);
            var signingKey = Convert.ToBase64String(binarySigningKey);
            var tokenDecryptionKey = Convert.ToBase64String(binaryTokenDecryptionKey);
            Console.WriteLine($"Sign: ${signingKey} | Decrypt: ${tokenDecryptionKey}");
        }
    }
}

Não esqueça de remover, ou pelo menos desativar este trecho do código.

Agora que geramos as chaves, devemos salvar elas em algum lugar, neste caso, no docker-compose.override.yml

version: '3.4'

services:
  qpanc.api:
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_URLS=https://+:443;http://+:80
      - DEFAULT_CONNECTION=Server=qpanc.database;Port=5432;Database=postgres;User Id=postgres;Password=keepitsupersecret;
      - JWTBEARER_VALIDISSUER=https://api.qpanc.app/
      - JWTBEARER_VALIDAUDIENCE=https://api.qpanc.app/
      - JWTBEARER_ISSUERSIGNINGKEY=itUXC7iVRsofSDWNeg/aLYpc4bMzHAsMPzeItE1PQi2tMK2f4t0InRgTE5B/4IAjhAX5LQSIGL1CaUHSSzED8A==
      - JWTBEARER_TOKENDECRYPTIONKEY=7hfboHG0d4GnXjVng0ukMo+IgrKKrPLUMtOvnt4S514=

Agora que criamos as chaves, falta apenas realizar a implementação do LoggedUser, mova ela de QPANC.Services para QPANC.Api/Services e altere a sua implementação para:

QPANC.Api.Services/LoggedUser

using Microsoft.AspNetCore.Http;
using Microsoft.IdentityModel.JsonWebTokens;
using QPANC.Services.Abstract;
using System;
using System.Linq;

namespace QPANC.Api.Services
{
    public class LoggedUser : ILoggedUser
    {
        private IHttpContextAccessor _context;
        public LoggedUser(IHttpContextAccessor context)
        {
            this._context = context;
            if (this._context.HttpContext.User.Identity.IsAuthenticated)
            {
                var sub = this._context.HttpContext.User.Claims
                        .Where(x => x.Type == ClaimTypes.NameIdentifier)
                        .Select(x => x.Value)
                        .FirstOrDefault();
                var jti = this._context.HttpContext.User.Claims
                        .Where(x => x.Type == JwtRegisteredClaimNames.Jti)
                        .Select(x => x.Value)
                        .FirstOrDefault();
                if (sub != default)
                {
                    this.UserId = Guid.Parse(sub);
                }
                if (jti != default)
                {
                    this.SessionId = Guid.Parse(jti);
                }
            }
        }

        public Guid? SessionId { get; private set; }
        public Guid? UserId { get; private set; }
    }
}

Já que houve uma mudança no namespace to LoggedUser, será preciso atualizar o Startup.cs no projeto QPANC.Api.

Caso não conheça a anatomia de um token JWT e não faça ideia do que é JTI e SUB, recomendo que leia sobre em JSON Web Tokens (Part 1).

Agora iremos criar um Options, ou seria um Middleware? ele será responsável por validar o token, e quando necessário, disponibilizar uma forma alternativa de passar o mesmo para API, como por exemplo, através da querystring, ao invés de um header.

./QPANC.Api/Options/JwtBearer.js

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using QPANC.Domain;
using QPANC.Services.Abstract;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Threading.Tasks;

namespace QPANC.Api.Options
{
    public class JwtBearer : IConfigureOptions<JwtBearerOptions>
    {
        private IServiceProvider _provider;
        private IJwtBearer _settings;

        public JwtBearer(IServiceProvider provider, IJwtBearer settings)
        {
            this._provider = provider;
            this._settings = settings;
        }

        private async Task OnTokenValidated(TokenValidatedContext context)
        {
            using (var scope = this._provider.CreateScope())
            {
                var db = scope.ServiceProvider.GetRequiredService<QpancContext>();
                var sessionId = Guid.Parse(context.Principal.Claims
                    .Where(x => x.Type == JwtRegisteredClaimNames.Jti)
                    .Select(x => x.Value)
                    .FirstOrDefault());

                var session = await db.Sessions.FindAsync(sessionId);
                if (session == null)
                {
                    context.Fail("");
                }
                else 
                {
                    context.Success();
                }
            }
        }

        private async Task OnMessageReceived(MessageReceivedContext context)
        {
            await Task.Yield();
            // var urlPath = context.Request.Path.ToString();
            // if (urlPath.StartsWith("/signalr/") && context.Request.Query.ContainsKey("bearer"))
            //     context.Token = context.Request.Query["bearer"];
        }

        public void Configure(JwtBearerOptions options)
        {
            options.RequireHttpsMetadata = false;
            options.SaveToken = true;
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidIssuer = _settings.ValidIssuer,
                ValidAudience = _settings.ValidAudience,
                IssuerSigningKey = new SymmetricSecurityKey(_settings.IssuerSigningKey),
                TokenDecryptionKey = new SymmetricSecurityKey(_settings.TokenDecryptionKey),
                RequireExpirationTime = false,
                ClockSkew = TimeSpan.Zero
            };

            options.Events = new JwtBearerEvents
            {
                OnMessageReceived = OnMessageReceived,
                OnTokenValidated = OnTokenValidated
            };
        }
    }
}

Como mencionado anteriormente, iremos utilizar a tabela Sessions para invalidar os tokens após um Logout, para que o token não possa ser usado por terceiros após ao usuário realizar um logout. Isto é feito no OnTokenValidated

Por enquanto o OnMessageReceived não fará muito, e é bastante provável que não o fará na maioria dos projetos. Mas ele poderá ser útil em alguns casos bem específicos, como autenticação por token em um Hub do SignalR.

Agora, que já escrevemos os serviços, options, middlewares, chaves, etc... temos que registrar tudo:

using QPANC.Api.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using System.IdentityModel.Tokens.Jwt;

namespace QPANC.Api
{
    public class Startup
    {
        private IServiceProvider _provider;

        public void ConfigureServices(IServiceCollection services)
        {
            JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
            services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            }).AddJwtBearer(
                authenticationScheme: JwtBearerDefaults.AuthenticationScheme,
                configureOptions: options =>
                {
                    _provider.GetRequiredService<IConfigureOptions<JwtBearerOptions>>().Configure(options);
                });
            services.ConfigureOptions<ConfigureOptions.JwtBearer>();
        }


        public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IMigrateLegacyService migrateLegacy)
        {
            this._provider = app.ApplicationServices;
        }
    }
}

12.1 Chore - Alteração na Estrutura dos serviços

Antes e continuamos, crie as pasta Infra e Business dentro do projeto QPANC.Services e QPANC.Servives.Abstract. Então mova todos os arquivos de ambos os projetos para a pasta Infra do respectivo projeto.

12.2 Sugestão - Tokens de breve duração com renovação automática.

Caso precise, implementar Tokens de breve duração com renovação automática, adicione o campo TokenId à tabela SessionId.

Na geração do Token, defina o ExpireAt para ocorrer em x minutos, o JTI será o TokenId e adicione uma nova claim com o SessionId.

Durante a validação do Token, verifique se a Session está ativa, e se o JTI do token atual corresponde ao TokenId.

E por fim, precisaremos de uma Action para renovar o token, o que basicamente irá criar um novo token com o mesmo SessionId, porém com a data de expiração atualizada, e um novo TokenId.

No tocante a aplicação Web, será necessário apenas adicionar um setInterval, que chame o endpoint de renovação a cada y minutos.

13 Controller para Autenticação

Vou começar este capítulo com uma nota/desabafo pessoal, eu não sou um grande fã de arquiteturas com excesso de organização, daquelas que ao abrir a solução, você se depara com 937 projetos. Isto ao meu ver, é apenas Over-Architecting, ou seja, um design ruim e inchado que é vendido como bom e indispensável para a manutenção do código.

Porém, vejo grande valor em separar as regras de negocio das peculiaridades técnicas da camada de apresentação, afinal, as regras de negocio devem ser as mesmas, independente do front ser uma aplicação Xamarim, WebAPI, Mvc WebPages, WCF (R.I.P.) ou NodeJS (sim, é possível compartilhar código entre uma aplicação Node e outra em C#, para saber mais, procure por EdgeJS).

De toda forma, como mencionei durante a Introdução, não se sinta intimidado por causa da minha opinião, caso veja valor neste tipo de arquitetura, siga em frente.

Instale o pacote Microsoft.

O primeiro serviço que iremos desenvolver, será responsável pelas rotinas de autenticação, que são: Login, Logout e Register.

O primeiro passo, será criar os Modelos POCO, que serão consumidos pelo Serviço:

A primeira classe, será o BaseResponse.cs, que é o objeto que será retornado por todos os métodos dos Serviços com regras de Negocio (Business). Estamos utilizando o HttpStatusCode como retorno, por ser um padrão conhecido e de fácil reconhecimento, porém os serviços podem vir a ser consumidos por aplicações fora da web.

QPANC.Services.Abstract/Models/BaseResponse.cs

using System.Collections.Generic;
using System.Net;

namespace QPANC.Services.Abstract
{
    public class BaseResponse
    {
        public BaseResponse() { }
        public BaseResponse(HttpStatusCode statusCode)
        {
            this.StatusCode = statusCode;
        }

        public HttpStatusCode StatusCode { get; set; }
        public Dictionary<string, string> Errors { get; set; }
    }

    public class BaseResponse<T> : BaseResponse
    {
        public BaseResponse() : base() { }
        public BaseResponse(HttpStatusCode statusCode) : base(statusCode) { }

        public T Data { get; set; }
    }
}

o Login, irá esperar um objeto do tipo LoginRequest e retornar um do tipo LoginResponse.

QPANC.Services.Abstract/Models/LoginRequest.cs

using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;

namespace QPANC.Services.Abstract
{
    [DataContract]
    public class LoginRequest
    {
        [EmailAddress(ErrorMessage = nameof(Messages.ErrorMessage_Email))]
        [Required(ErrorMessage = nameof(Messages.ErrorMessage_Required))]
        [Display(Name = nameof(Messages.Field_UserName))]
        [DataMember]
        public string UserName { get; set; }

        [Required(ErrorMessage = nameof(Messages.ErrorMessage_Required))]
        [Display(Name = nameof(Messages.Field_Password))]
        [DataMember]
        public string Password { get; set; }
    }
}

QPANC.Services.Abstract/Models/LoginRequest.cs

using System;
using System.Runtime.Serialization;

namespace QPANC.Services.Abstract
{
    [DataContract]
    public class LoginResponse
    {
        [DataMember]
        public Guid SessionId { get; set; }
        [DataMember]
        public Guid UserId { get; set; }
        [DataMember]
        public string UserName { get; set; }
        [DataMember]
        public DateTimeOffset ExpiresAt { get; set; }
    }
}

Note a presença das seguintes declarações: nameof(Messages.ErrorMessage_Required), nameof(Messages.Field_UserName), nameof(Messages.Field_Password). Como discutido no capitulo de regionalização, elas são necessárias para localizar a aplicação.

Agora, crie a interface IAuthentication.cs na pasta Business e adicionar o método Login

QPANC.Services.Abstract/Business/IAuthentication.cs

using System.Threading.Tasks;

namespace QPANC.Services.Abstract
{
    public interface IAuthentication
    {
        Task<BaseResponse<LoginResponse>> Login(LoginRequest login);
    }
}

A implementação, será feita no projeto QPANC.Services, no arquivo Authentication.cs dentro da pasta Business

QPANC.Services.Abstract/Business/IAuthentication.cs

using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Localization;
using QPANC.Domain;
using QPANC.Domain.Identity;
using QPANC.Services.Abstract;
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;

namespace QPANC.Services
{
    public class Authentication : IAuthentication
    {
        private readonly UserManager<User> _userManager;
        private readonly QpancContext _context;
        private readonly ISGuid _sguid;
        private readonly ILoggedUser _loggedUser;
        private readonly IStringLocalizer _localizer;

        public Authentication(
            UserManager<User> userManager,
            QpancContext context,
            ISGuid sguid,
            ILoggedUser loggedUser,
            IStringLocalizer<Messages> localizer)
        {
            this._userManager = userManager;
            this._context = context;
            this._sguid = sguid;
            this._loggedUser = loggedUser
            this._localizer = localizer;

        }

        public async Task<BaseResponse<LoginResponse>> Login(LoginRequest login)
        {
            var incorrectPasswordOrUsername = new BaseResponse<LoginResponse>
            {
                StatusCode = HttpStatusCode.UnprocessableEntity,
                Errors = new Dictionary<string, string>
                {
                    { nameof(login.Password), this._localizer[nameof(Messages.ErrorMessage_IncorrectPasswordOrUsername)] }
                }
            };

            var user = await this._userManager.FindByNameAsync(login.UserName);
            if (user == default)
            {
                return incorrectPasswordOrUsername;
            }

            var isAuthenticated = await this._userManager.CheckPasswordAsync(user, login.Password);
            if (!isAuthenticated)
            {
                return incorrectPasswordOrUsername;
            }

            var expiresAt = DateTimeOffset.Now.AddYears(1);
            var sessionId = this._sguid.NewGuid();
            var session = new Domain.Identity.Session
            {
                SessionId = sessionId,
                UserId = user.Id,
                ExpireAt = expiresAt
            };
            this._context.Sessions.Add(session);
            await this._context.SaveChangesAsync();

            return new BaseResponse<LoginResponse>
            {
                StatusCode = HttpStatusCode.OK,
                Data = new LoginResponse
                { 
                    SessionId = session.SessionId,
                    UserId = session.UserId,
                    UserName = user.UserName,
                    ExpiresAt = session.ExpireAt
                }
            };
        }
    }
}

Note, que este método, valida se o usuário existe, se o password está correto, cria a sessão que será utilizada para rastrear/verificar o usuário, porém não cria o Token, afinal o uso de Token JWT é uma estrategia utilizada por APIs REST/GraphQL, mas outras interfaces, podem vir a adotar outras estrategias, como Cookies, Windows Login, (Azure) Active Directory, etc.

Falando em Tokens, adicione a interface ITokenGenerator ao projeto QPANC.Services dentro da pasta Infra

QPANC.Services.Abstract/Infra/ITokenGenerator.cs

using System.Threading.Tasks;

namespace QPANC.Services.Abstract
{
    public interface ITokenGenerator
    {
        Task<string> Generate(LoginResponse userId);
    }
}

E agora a implementação, que será feita no projeto da API.

QPANC.Api/Services/TokenGenerator.cs

using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using QPANC.Domain;
using QPANC.Services.Abstract;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames;

namespace QPANC.Api.Services
{
    public class TokenGenerator : ITokenGenerator
    {
        private QpancContext _context;
        private IJwtBearer _jwtBearer;

        public TokenGenerator(QpancContext context, IJwtBearer jwtBearer)
        {
            this._context = context;
            this._jwtBearer = jwtBearer;
        }

        public async Task<string> Generate(LoginResponse login)
        {
            var roles = await this._context.UserRoles
                .Where(x => x.UserId == login.UserId)
                .Select(x => x.Role.Name)
                .ToListAsync();

            var claims = new List<Claim>
            {
                new Claim(JwtRegisteredClaimNames.Sub, login.UserName),
                new Claim(JwtRegisteredClaimNames.Jti, login.SessionId.ToString()),
                new Claim(ClaimTypes.NameIdentifier, login.UserId.ToString())
            };

            foreach (var role in roles)
            {
                claims.Add(new Claim(ClaimTypes.Role, role));
            }

            var keySigning = new SymmetricSecurityKey(this._jwtBearer.IssuerSigningKey);
            var signing = new SigningCredentials(keySigning, SecurityAlgorithms.HmacSha256);

            // var keyEncrypting = new SymmetricSecurityKey(this._jwtBearer.TokenDecryptionKey);
            // var encrypting = new EncryptingCredentials(keyEncrypting, SecurityAlgorithms.Aes256KW, SecurityAlgorithms.Aes256CbcHmacSha512);

            var handler = new JwtSecurityTokenHandler();
            handler.OutboundClaimTypeMap.Clear();

            var token = handler.CreateJwtSecurityToken(
                issuer: this._jwtBearer.ValidIssuer,
                audience: this._jwtBearer.ValidAudience,
                subject: new ClaimsIdentity(claims),
                notBefore: DateTime.Now,
                expires: login.ExpiresAt.UtcDateTime,
                issuedAt: DateTime.Now,
                signingCredentials: signing
                // encryptingCredentials: encrypting
            );
            return handler.WriteToken(token);
        }
    }
}

Note que o keyEncrypting e o encrypting estão comentados, ative estas linhas, caso deseje que o payload do token seja criptografado, lembrando que ao faze-lo, a aplicação cliente (browser) não será capaz de ler o payload.

Antes de criamos a AuthController, iremos criar à ControllerBase, ela terá os métodos que serão comuns as demais Controllers.

QPANC.Api/Controllers/ControllerBase.cs

using Microsoft.AspNetCore.Mvc;
using QPANC.Services.Abstract;

namespace QPANC.Api.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class ControllerBase : Microsoft.AspNetCore.Mvc.ControllerBase
    {
        internal IActionResult ParseResult<T>(BaseResponse<T> result)
        {
            if (result.StatusCode == System.Net.HttpStatusCode.OK)
            {
                return Ok(result.Data);
            }
            else if (result.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity)
            {
                foreach (var error in result.Errors)
                {
                    ModelState.AddModelError(error.Key, error.Value);
                }
                var options = HttpContext.RequestServices.GetRequiredService<IOptions<ApiBehaviorOptions>>();
                return options.Value.InvalidModelStateResponseFactory(ControllerContext);
            }
            else
            {
                return StatusCode((int)result.StatusCode);
            }
        }

        internal IActionResult ParseResult(BaseResponse result)
        {
            if (result.StatusCode == System.Net.HttpStatusCode.OK)
            {
                return Ok();
            }
            else if (result.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity)
            {
                foreach (var error in result.Errors)
                {
                    ModelState.AddModelError(error.Key, error.Value);
                }
                var options = HttpContext.RequestServices.GetRequiredService<IOptions<ApiBehaviorOptions>>();
                return options.Value.InvalidModelStateResponseFactory(ControllerContext);
            }
            else
            {
                return StatusCode((int)result.StatusCode);
            }
        }
    }
}

e agora, a tão esperada AuthController

QPANC.Api/Controllers/AuthController.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using QPANC.Services.Abstract;
using System.Threading.Tasks;

namespace QPANC.Api.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class AuthController : ControllerBase
    {
        private readonly ILogger<WeatherForecastController> _logger;
        private readonly IAuthentication _authentication;
        private readonly ITokenGenerator _tokenGenerator;

        public AuthController(IAuthentication authentication, ITokenGenerator tokenGenerator, ILogger<WeatherForecastController> logger)
        {
            this._authentication = authentication;
            this._tokenGenerator = tokenGenerator;
            this._logger = logger;
        }

        [HttpPost]
        [Route("[action]")]
        public async Task<IActionResult> Login(LoginRequest model)
        {
            var result = await this._authentication.Login(model);
            if (result.StatusCode == System.Net.HttpStatusCode.OK)
            {
                var token = await this._tokenGenerator.Generate(result.Data);
                return Ok(token);
            }
            return this.ParseResult(result);
        }
    }
}

Normalmente as nossasactions (métodos das controllers) terão apenas duas linhas, o login acima é uma exceção, devido a necessidade de gerar o Token.

Antes de podemos testar, teremos de registrar todos os serviços, pois infelizmente eles não se registram sozinhos (ainda).

QPANC.Api/Startup.cs

namespace QPANC.Api
{
    public class Startup
    {

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddScoped<ITokenGenerator, TokenGenerator>();
            services.AddScoped<IAuthentication, Authentication>();
        }
    }
}

Agora podemos executar a aplicação para realizar os nossos testes:

Alt Text

Primeiro, faça um POST para /Auth/Login passando o userName e/ou o password vazios. Como ambos foram especificados como requeridos no modelo, a API irá identificar que o modelo está invalido, e irá retornar um Status 422 com os respectivos erros. Vale lembrar, que neste caso, a chamada não chegará a Action.

Alt Text

Como o meu navegador está em português, as mensagens foram exibidas em português, agora iremos alterar a linguagem, no meu caso para inglês, e tentar novamente:

Alt Text

No próximo teste, iremos informar um e-mail inexistente ou senha incorreta:

Alt Text

Desta vez, como o modelo estava valido, a requisição chegou a Action, porém ocorreu uma falha durante uma das validações.

Por fim, iremos informar o um email existente e a sua respectiva senha, caso não se lembre deles, consulte o serviço Seeder.

Alt Text

Finalmente, tivemos um retorno 200 (Success) e com isto obtivemos o nosso primeiro token.:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZXZlbG9wZXJAcXBhbmMuYXBwIiwianRpIjoiMDE3MTMzNjktNTE5Yy00NTkzLWJmM2UtMjc4NDRlNjkwMzNkIiwiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZWlkZW50aWZpZXIiOiIwMTcxMzM1NC1kZjlkLTRmYzgtYjIxYi1iNDI1ODNkOTNkODkiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOiJEZXZlbG9wZXIiLCJuYmYiOjE1ODU3MDU0NzIsImV4cCI6MTYxNzI0MTQ3MiwiaWF0IjoxNTg1NzA1NDcyLCJpc3MiOiJodHRwczovL2FwaS5xcGFuYy5hcHAvIiwiYXVkIjoiaHR0cHM6Ly9hcGkucXBhbmMuYXBwLyJ9.8KzqLIOKgv0rZwQQbZqeE9lf96yh18VCbPZ1m5txZ9Q

Você poderá decodificar este token no site JWT.io

Alt Text

Assim como, poderá usar a claim JTI, para consultar a sessão associada a este Token no Banco de Dados.

Alt Text

Agora que conseguimos realizar um login, vamos implementar o logout, primeiramente, adicione o seguinte método a interface IAuthentication

QPANC.Services.Abstract/Business/IAuthentication.cs

public interface IAuthentication
{
    Task<BaseResponse> Logout();
}

Assim, como a sua respectiva implementação no serviço Authentication

QPANC.Services/Business/Authentication.cs

public class Authentication : IAuthentication
{
    public async Task<BaseResponse> Logout()
    {
        var session = await this._context.Sessions.FindAsync(this._loggedUser.SessionId);
        if (session == default)
        {
            return new BaseResponse(statusCode: HttpStatusCode.NotFound);
        }
        this._context.Sessions.Remove(session);
        await this._context.SaveChangesAsync();
        return new BaseResponse(statusCode: HttpStatusCode.OK);
    }
}

E por fim, a Action Logout na controller AuthController

QPANC.Api/Controllers/AuthController.cs

using Microsoft.AspNetCore.Authorization;

namespace QPANC.Api.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class AuthController : ControllerBase
    {
        [Authorize]
        [HttpDelete]
        [Route("[action]")]
        public async Task<IActionResult> Logout()
        {
            var result = await this._authentication.Logout();
            return this.ParseResult(result);
        }
    }
}

Note a presença do Atributo Authorize, ele é necessário, para garantir que apenas usuários logados possam realizar um Logout, agora vamos aos testes.:

Primeiro, vamos tentar realizar um logout, sem passar o token jwt nos headers:

Alt Text

Como esperado, a API retornou o status 401, ou seja, não autenticado. Antes de realizar o próximo teste, precisamos nos autenticar, para fazer isto na interface do Swagger UI, clique o botão com o rotulo "Authorize", e cole bearer ${Token}

Alt Text

Agora, chame novamente a Action /Auth/Logout.

Alt Text

Lembrando que, no caso de um Token JWT, o Logout não precisa tomar nenhuma ação adicional, mas em outras estrategias, como por exemplo cookies, pode vir a exigir alguns passos extras.

E para finalizamos este capitulo, precisamos criar a opção de registrar um usuário.

como de praxe, iremos iniciar o desenvolvimento desta ação pelo modelo:

QPANC.Services.Abstract/Models/RegisterRequest.cs

using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;

namespace QPANC.Services.Abstract
{
    [DataContract]
    public class RegisterRequest
    {
        [EmailAddress(ErrorMessage = nameof(Messages.ErrorMessage_Required))]
        [Required(ErrorMessage = nameof(Messages.ErrorMessage_Required))]
        [Display(Name = nameof(Messages.Field_UserName))]
        [DataMember]
        public string UserName { get; set; }

        [Compare(nameof(RegisterRequest.UserName), ErrorMessage = nameof(Messages.ErrorMessage_Compare))]
        [EmailAddress(ErrorMessage = nameof(Messages.ErrorMessage_Required))]
        [Required(ErrorMessage = nameof(Messages.ErrorMessage_Required))]
        [Display(Name = nameof(Messages.Field_ConfirmUserName))]
        [DataMember]
        public string ConfirmUserName { get; set; }

        [Required(ErrorMessage = nameof(Messages.ErrorMessage_Required))]
        [Display(Name = nameof(Messages.Field_FirstName))]
        [DataMember]
        public string FirstName { get; set; }

        [Required(ErrorMessage = nameof(Messages.ErrorMessage_Required))]
        [Display(Name = nameof(Messages.Field_LastName))]
        [DataMember]
        public string LastName { get; set; }

        [Required(ErrorMessage = nameof(Messages.ErrorMessage_Required))]
        [Display(Name = nameof(Messages.Field_Password))]
        [DataMember]
        public string Password { get; set; }

        [Compare(nameof(RegisterRequest.Password), ErrorMessage = nameof(Messages.ErrorMessage_Compare))]
        [Required(ErrorMessage = nameof(Messages.ErrorMessage_Required))]
        [Display(Name = nameof(Messages.Field_ConfirmPassword))]
        [DataMember]
        public string ConfirmPassword { get; set; }
    }
}

então, devemos incrementar o nosso serviço de Autenticação:

QPANC.Services.Abstract/Business/IAuthentication.cs

public interface IAuthentication
{
    Task<BaseResponse> Register(RegisterRequest register);
}

QPANC.Services/Business/Authentication.cs

public class Authentication
{
    public async Task<BaseResponse> Register(RegisterRequest register)
    {
        var userNameAlreadyTaken = new BaseResponse<LoginResponse>
        {
            StatusCode = HttpStatusCode.UnprocessableEntity,
            Errors = new Dictionary<string, string>
            {
                { nameof(register.UserName), this._localizer[nameof(Messages.ErrorMessage_UserNameAlreadyTaken)] }
            }
        };

        var passwordTooWeak = new BaseResponse<LoginResponse>
        {
            StatusCode = HttpStatusCode.UnprocessableEntity,
            Errors = new Dictionary<string, string>
            {
                { nameof(register.Password), this._localizer[nameof(Messages.ErrorMessage_PasswordTooWeak)] }
            }
        };

        var user = await this._userManager.FindByNameAsync(register.UserName);
        if (user != default)
        {
            return userNameAlreadyTaken;
        }

        user = new User()
        {
            UserName = register.UserName,
            FirstName = register.FirstName,
            LastName = register.LastName
        };
        var result = await this._userManager.CreateAsync(user, register.Password);
        if (result.Succeeded)
        {
            return new BaseResponse(statusCode: HttpStatusCode.OK);
        }
        else
        {
            // email is valid, that isn't take, so if something goes whong, this must be the password =D
            return passwordTooWeak;
        }
    }
}

E claro, temos de adicionar uma Action à Controller.

namespace QPANC.Api.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class AuthController : ControllerBase
    {
        [HttpPost]
        [Route("[action]")]
        public async Task<IActionResult> Register(RegisterRequest model)
        {
            var result = await this._authentication.Register(model);
            return this.ParseResult(result);
        }
    }
}

Agora vamos fazer alguns testes, primeiro usando um modelo inconsistente, então um com um email em uso, depois uma senha fraca.

Alt Text
Alt Text
Alt Text

E finalmente, um cadastro com modelo valido, email disponível e senha forte.

Alt Text

Discussion (0)