diff --git a/Certificado ASP.NET Core Enterprise Application.png b/Certificado ASP.NET Core Enterprise Application.png deleted file mode 100644 index a333cfd..0000000 Binary files a/Certificado ASP.NET Core Enterprise Application.png and /dev/null differ diff --git "a/Certificado Forma\303\247\303\243o Full Stack Developer.png" "b/Certificado Forma\303\247\303\243o Full Stack Developer.png" deleted file mode 100644 index 3debb99..0000000 Binary files "a/Certificado Forma\303\247\303\243o Full Stack Developer.png" and /dev/null differ diff --git a/src/building blocks/JSE.WebAPI.Core/User/AspNetUser.cs b/src/building blocks/JSE.WebAPI.Core/User/AspNetUser.cs index 7e37e8f..fa04ec2 100644 --- a/src/building blocks/JSE.WebAPI.Core/User/AspNetUser.cs +++ b/src/building blocks/JSE.WebAPI.Core/User/AspNetUser.cs @@ -29,6 +29,11 @@ public string ObterUserToken() return EstaAutenticado() ? _accessor.HttpContext.User.GetUserToken() : ""; } + public string ObterUserRefreshToken() + { + return EstaAutenticado() ? _accessor.HttpContext.User.GetUserRefreshToken() : ""; + } + public bool EstaAutenticado() { return _accessor.HttpContext.User.Identity.IsAuthenticated; diff --git a/src/building blocks/JSE.WebAPI.Core/User/ClaimsPrincipalExtensions.cs b/src/building blocks/JSE.WebAPI.Core/User/ClaimsPrincipalExtensions.cs index e01d2b8..e2835a9 100644 --- a/src/building blocks/JSE.WebAPI.Core/User/ClaimsPrincipalExtensions.cs +++ b/src/building blocks/JSE.WebAPI.Core/User/ClaimsPrincipalExtensions.cs @@ -36,5 +36,17 @@ public static string GetUserToken(this ClaimsPrincipal principal) var claim = principal.FindFirst("JWT"); return claim?.Value; } + + public static string GetUserRefreshToken(this ClaimsPrincipal principal) + { + if (principal == null) + { + throw new ArgumentException(nameof(principal)); + } + + var claim = principal.FindFirst("RefreshToken"); + return claim?.Value; + } + } } \ No newline at end of file diff --git a/src/building blocks/JSE.WebAPI.Core/User/IAspNetUser.cs b/src/building blocks/JSE.WebAPI.Core/User/IAspNetUser.cs index 718e0e5..e7c19f3 100644 --- a/src/building blocks/JSE.WebAPI.Core/User/IAspNetUser.cs +++ b/src/building blocks/JSE.WebAPI.Core/User/IAspNetUser.cs @@ -9,6 +9,7 @@ public interface IAspNetUser Guid ObterUserId(); string ObterUserEmail(); string ObterUserToken(); + string ObterUserRefreshToken(); bool EstaAutenticado(); bool PossuiRole(string role); IEnumerable ObterClaims(); diff --git a/src/services/JSE.Identidade.API/Configuration/ApiConfig.cs b/src/services/JSE.Identidade.API/Configuration/ApiConfig.cs index 2212fc1..fccb00e 100644 --- a/src/services/JSE.Identidade.API/Configuration/ApiConfig.cs +++ b/src/services/JSE.Identidade.API/Configuration/ApiConfig.cs @@ -1,4 +1,5 @@ -using JSE.WebAPI.Core.IdentityConfiguration; +using JSE.Identidade.API.Services; +using JSE.WebAPI.Core.IdentityConfiguration; using JSE.WebAPI.Core.User; using NetDevPack.Security.JwtSigningCredentials.AspNetCore; @@ -10,6 +11,7 @@ public static IServiceCollection AddApiConfiguration(this IServiceCollection ser { services.AddControllers(); + services.AddScoped(); services.AddScoped(); return services; diff --git a/src/services/JSE.Identidade.API/Configuration/IdentityConfig.cs b/src/services/JSE.Identidade.API/Configuration/IdentityConfig.cs index 6fa7604..e4faa15 100644 --- a/src/services/JSE.Identidade.API/Configuration/IdentityConfig.cs +++ b/src/services/JSE.Identidade.API/Configuration/IdentityConfig.cs @@ -12,6 +12,9 @@ public static IServiceCollection AddIdentityConfiguration(this IServiceCollectio IConfiguration configuration) { + var appSettingsSection = configuration.GetSection("AppTokenSettings"); + services.Configure(appSettingsSection); + services.AddJwksManager(options => options.Algorithm = Algorithm.ES256) .PersistKeysToDatabaseStore(); diff --git a/src/services/JSE.Identidade.API/Controllers/AuthController.cs b/src/services/JSE.Identidade.API/Controllers/AuthController.cs index 728503a..84b18df 100644 --- a/src/services/JSE.Identidade.API/Controllers/AuthController.cs +++ b/src/services/JSE.Identidade.API/Controllers/AuthController.cs @@ -1,44 +1,29 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; +using System; +using System.Threading.Tasks; +using JSE.Core.Messages.Integration; +using JSE.MessageBus; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; +using JSE.Core.Messages.Integration; using JSE.Identidade.API.Models; -using JSE.WebAPI.Core.Controllers; +using JSE.Identidade.API.Services; using JSE.MessageBus; -using JSE.Core.Messages.Integration; -using JSE.WebAPI.Core.IdentityConfiguration; -using JSE.WebAPI.Core.User; -using NetDevPack.Security.JwtSigningCredentials.Interfaces; +using JSE.WebAPI.Core.Controllers; namespace JSE.Identidade.API.Controllers { [Route("api/identidade")] public class AuthController : MainController { - private readonly SignInManager _signInManager; - private readonly UserManager _userManager; - private readonly AppSettings _appSettings; - private readonly IAspNetUser _aspNetUser; - private readonly IJsonWebKeySetService _jwksService; - + private readonly AuthenticationService _authenticationService; private readonly IMessageBus _bus; - public AuthController(SignInManager signInManager, - UserManager userManager, - IOptions appSettings, - IMessageBus bus, - IAspNetUser aspNetUser, - IJsonWebKeySetService jwksService) + public AuthController( + AuthenticationService authenticationService, + IMessageBus bus) { - _signInManager = signInManager; - _userManager = userManager; - _appSettings = appSettings.Value; + _authenticationService = authenticationService; _bus = bus; - _aspNetUser = aspNetUser; - _jwksService = jwksService; } [HttpPost("nova-conta")] @@ -53,19 +38,19 @@ public async Task Registrar(UsuarioRegistroViewModel usuarioRegist EmailConfirmed = true }; - var result = await _userManager.CreateAsync(user, usuarioRegistro.Senha); + var result = await _authenticationService.UserManager.CreateAsync(user, usuarioRegistro.Senha); if (result.Succeeded) { var clienteResult = await RegistrarCliente(usuarioRegistro); - if(!clienteResult.ValidationResult.IsValid) + if (!clienteResult.ValidationResult.IsValid) { - await _userManager.DeleteAsync(user); + await _authenticationService.UserManager.DeleteAsync(user); return CustomResponse(clienteResult.ValidationResult); } - return CustomResponse(await GerarJwt(usuarioRegistro.Email)); + return CustomResponse(await _authenticationService.GerarJwt(usuarioRegistro.Email)); } foreach (var error in result.Errors) @@ -79,15 +64,14 @@ public async Task Registrar(UsuarioRegistroViewModel usuarioRegist [HttpPost("autenticar")] public async Task Login(UsuarioLoginViewModel usuarioLogin) { - if (!ModelState.IsValid) return CustomResponse(ModelState); - var result = await _signInManager.PasswordSignInAsync(usuarioLogin.Email, usuarioLogin.Senha, + var result = await _authenticationService.SignInManager.PasswordSignInAsync(usuarioLogin.Email, usuarioLogin.Senha, false, true); if (result.Succeeded) { - return CustomResponse(await GerarJwt(usuarioLogin.Email)); + return CustomResponse(await _authenticationService.GerarJwt(usuarioLogin.Email)); } if (result.IsLockedOut) @@ -100,91 +84,42 @@ public async Task Login(UsuarioLoginViewModel usuarioLogin) return CustomResponse(); } - private async Task GerarJwt(string email) - { - var user = await _userManager.FindByEmailAsync(email); - var claims = await _userManager.GetClaimsAsync(user); - - var identityClaims = await ObterClaimsUsuario(claims, user); - var encodedToken = CodificarToken(identityClaims); - - return ObterRespostaToken(encodedToken, user, claims); - } - - private async Task ObterClaimsUsuario(ICollection claims, IdentityUser user) + private async Task RegistrarCliente(UsuarioRegistroViewModel usuarioRegistro) { - var userRoles = await _userManager.GetRolesAsync(user); + var usuario = await _authenticationService.UserManager.FindByEmailAsync(usuarioRegistro.Email); - claims.Add(new Claim(JwtRegisteredClaimNames.Sub, user.Id)); - claims.Add(new Claim(JwtRegisteredClaimNames.Email, user.Email)); - claims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())); - claims.Add(new Claim(JwtRegisteredClaimNames.Nbf, ToUnixEpochDate(DateTime.UtcNow).ToString())); - claims.Add(new Claim(JwtRegisteredClaimNames.Iat, ToUnixEpochDate(DateTime.UtcNow).ToString(), ClaimValueTypes.Integer64)); + var usuarioRegistrado = new UsuarioRegistradoIntegrationEvent( + Guid.Parse(usuario.Id), usuarioRegistro.Nome, usuarioRegistro.Email, usuarioRegistro.Cpf); - foreach (var userRole in userRoles) + try { - claims.Add(new Claim("role", userRole)); + return await _bus.RequestAsync(usuarioRegistrado); } - - var identityClaims = new ClaimsIdentity(); - identityClaims.AddClaims(claims); - - return identityClaims; - } - - private string CodificarToken(ClaimsIdentity identityClaims) - { - var tokenHandler = new JwtSecurityTokenHandler(); - - var currentIssuer = $"{_aspNetUser.ObterHttpContext().Request.Scheme}://{_aspNetUser.ObterHttpContext().Request.Host}"; - - var key = _jwksService.GetCurrent(); - var token = tokenHandler.CreateToken(new SecurityTokenDescriptor + catch { - Issuer = currentIssuer, - Subject = identityClaims, - Expires = DateTime.UtcNow.AddHours(1), - SigningCredentials = key - }); - - return tokenHandler.WriteToken(token); + await _authenticationService.UserManager.DeleteAsync(usuario); + throw; + } } - private UsuarioRespostaLoginViewModel ObterRespostaToken(string encodedToken, IdentityUser user, IEnumerable claims) + [HttpPost("refresh-token")] + public async Task RefreshToken([FromBody] string refreshToken) { - return new UsuarioRespostaLoginViewModel + if (string.IsNullOrEmpty(refreshToken)) { - AccessToken = encodedToken, - ExpiresIn = TimeSpan.FromHours(1).TotalSeconds, - UsuarioToken = new UsuarioTokenViewModel - { - Id = user.Id, - Email = user.Email, - Claims = claims.Select(c => new UsuarioClaimViewModel { Type = c.Type, Value = c.Value }) - } - }; - } - - private static long ToUnixEpochDate(DateTime date) - => (long)Math.Round((date.ToUniversalTime() - new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero)).TotalSeconds); - - private async Task RegistrarCliente(UsuarioRegistroViewModel usuarioRegistro) - { - var usuario = await _userManager.FindByEmailAsync(usuarioRegistro.Email); - - var usuarioRegistrado = new UsuarioRegistradoIntegrationEvent( - Guid.Parse(usuario.Id), usuarioRegistro.Nome, usuarioRegistro.Email, usuarioRegistro.Cpf); + AddProcessingError("Refresh Token inválido"); + return CustomResponse(); + } + var token = await _authenticationService.ObterRefreshToken(Guid.Parse(refreshToken)); - try - { - return await _bus.RequestAsync(usuarioRegistrado); - } - catch + if (token is null) { - await _userManager.DeleteAsync(usuario); - throw; + AddProcessingError("Refresh Token expirado"); + return CustomResponse(); } + + return CustomResponse(await _authenticationService.GerarJwt(token.UserName)); } } } \ No newline at end of file diff --git a/src/services/JSE.Identidade.API/Data/ApplicationDbContext.cs b/src/services/JSE.Identidade.API/Data/ApplicationDbContext.cs index af260b1..1ede1a1 100644 --- a/src/services/JSE.Identidade.API/Data/ApplicationDbContext.cs +++ b/src/services/JSE.Identidade.API/Data/ApplicationDbContext.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using JSE.Identidade.API.Models; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using NetDevPack.Security.JwtSigningCredentials; using NetDevPack.Security.JwtSigningCredentials.Store.EntityFrameworkCore; @@ -10,5 +11,7 @@ public class ApplicationDbContext : IdentityDbContext, ISecurityKeyContext public ApplicationDbContext(DbContextOptions options) : base(options) { } public DbSet SecurityKeys { get; set; } + + public DbSet RefreshTokens { get; set; } } } diff --git a/src/services/JSE.Identidade.API/Extensions/AppTokenSettings.cs b/src/services/JSE.Identidade.API/Extensions/AppTokenSettings.cs new file mode 100644 index 0000000..87fdcb9 --- /dev/null +++ b/src/services/JSE.Identidade.API/Extensions/AppTokenSettings.cs @@ -0,0 +1,7 @@ +namespace JSE.Identidade.API.Extensions +{ + public class AppTokenSettings + { + public int RefreshTokenExpiration { get; set; } + } +} diff --git a/src/services/JSE.Identidade.API/Migrations/20250115141850_RefreshToken.Designer.cs b/src/services/JSE.Identidade.API/Migrations/20250115141850_RefreshToken.Designer.cs new file mode 100644 index 0000000..86fce9e --- /dev/null +++ b/src/services/JSE.Identidade.API/Migrations/20250115141850_RefreshToken.Designer.cs @@ -0,0 +1,330 @@ +// +using System; +using JSE.Identidade.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace JSE.Identidade.API.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250115141850_RefreshToken")] + partial class RefreshToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("JSE.Identidade.API.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpirationDate") + .HasColumnType("datetime2"); + + b.Property("Token") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NetDevPack.Security.JwtSigningCredentials.SecurityKeyWithPrivate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Algorithm") + .HasColumnType("nvarchar(max)"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("KeyId") + .HasColumnType("nvarchar(max)"); + + b.Property("Parameters") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("SecurityKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/services/JSE.Identidade.API/Migrations/20250115141850_RefreshToken.cs b/src/services/JSE.Identidade.API/Migrations/20250115141850_RefreshToken.cs new file mode 100644 index 0000000..d9e0bdd --- /dev/null +++ b/src/services/JSE.Identidade.API/Migrations/20250115141850_RefreshToken.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JSE.Identidade.API.Migrations +{ + /// + public partial class RefreshToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UserName = table.Column(type: "nvarchar(max)", nullable: false), + Token = table.Column(type: "uniqueidentifier", nullable: false), + ExpirationDate = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens"); + } + } +} diff --git a/src/services/JSE.Identidade.API/Migrations/ApplicationDbContextModelSnapshot.cs b/src/services/JSE.Identidade.API/Migrations/ApplicationDbContextModelSnapshot.cs index 92a12c5..d1068ab 100644 --- a/src/services/JSE.Identidade.API/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/services/JSE.Identidade.API/Migrations/ApplicationDbContextModelSnapshot.cs @@ -22,6 +22,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("JSE.Identidade.API.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpirationDate") + .HasColumnType("datetime2"); + + b.Property("Token") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("RefreshTokens"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") diff --git a/src/services/JSE.Identidade.API/Models/RefreshToken.cs b/src/services/JSE.Identidade.API/Models/RefreshToken.cs new file mode 100644 index 0000000..409bd3e --- /dev/null +++ b/src/services/JSE.Identidade.API/Models/RefreshToken.cs @@ -0,0 +1,17 @@ +namespace JSE.Identidade.API.Models +{ + public class RefreshToken + { + public RefreshToken() + { + Id = Guid.NewGuid(); + Token = Guid.NewGuid(); + } + + public Guid Id { get; set; } + public string UserName { get; set; } + public Guid Token { get; set; } + public DateTime ExpirationDate { get; set; } + + } +} diff --git a/src/services/JSE.Identidade.API/Models/UsuarioRespostaLoginViewModel.cs b/src/services/JSE.Identidade.API/Models/UsuarioRespostaLoginViewModel.cs index ea97931..a8a9776 100644 --- a/src/services/JSE.Identidade.API/Models/UsuarioRespostaLoginViewModel.cs +++ b/src/services/JSE.Identidade.API/Models/UsuarioRespostaLoginViewModel.cs @@ -5,5 +5,6 @@ public class UsuarioRespostaLoginViewModel public string AccessToken { get; set; } public double ExpiresIn { get; set; } public UsuarioTokenViewModel UsuarioToken { get; set; } + public Guid RefreshToken { get; set; } } } diff --git a/src/services/JSE.Identidade.API/Services/AuthenticationService.cs b/src/services/JSE.Identidade.API/Services/AuthenticationService.cs new file mode 100644 index 0000000..eb11e74 --- /dev/null +++ b/src/services/JSE.Identidade.API/Services/AuthenticationService.cs @@ -0,0 +1,142 @@ +using JSE.Identidade.API.Data; +using JSE.Identidade.API.Extensions; +using JSE.Identidade.API.Models; +using JSE.WebAPI.Core.User; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using NetDevPack.Security.JwtSigningCredentials.Interfaces; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace JSE.Identidade.API.Services +{ + public class AuthenticationService + { + public readonly SignInManager SignInManager; + public readonly UserManager UserManager; + private readonly AppSettings _appSettings; + private readonly AppTokenSettings _appTokenSettingsSettings; + private readonly ApplicationDbContext _context; + + private readonly IJsonWebKeySetService _jwksService; + private readonly IAspNetUser _aspNetUser; + + public AuthenticationService( + SignInManager signInManager, + UserManager userManager, + IOptions appSettings, + IOptions appTokenSettingsSettings, + ApplicationDbContext context, + IJsonWebKeySetService jwksService, + IAspNetUser aspNetUser) + { + SignInManager = signInManager; + UserManager = userManager; + _appSettings = appSettings.Value; + _appTokenSettingsSettings = appTokenSettingsSettings.Value; + _jwksService = jwksService; + _aspNetUser = aspNetUser; + _context = context; + } + + public async Task GerarJwt(string email) + { + var user = await UserManager.FindByEmailAsync(email); + var claims = await UserManager.GetClaimsAsync(user); + + var identityClaims = await ObterClaimsUsuario(claims, user); + var encodedToken = CodificarToken(identityClaims); + + var refreshToken = await GerarRefreshToken(email); + + return ObterRespostaToken(encodedToken, user, claims, refreshToken); + } + + private async Task ObterClaimsUsuario(ICollection claims, IdentityUser user) + { + var userRoles = await UserManager.GetRolesAsync(user); + + claims.Add(new Claim(JwtRegisteredClaimNames.Sub, user.Id)); + claims.Add(new Claim(JwtRegisteredClaimNames.Email, user.Email)); + claims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())); + claims.Add(new Claim(JwtRegisteredClaimNames.Nbf, ToUnixEpochDate(DateTime.UtcNow).ToString())); + claims.Add(new Claim(JwtRegisteredClaimNames.Iat, ToUnixEpochDate(DateTime.UtcNow).ToString(), + ClaimValueTypes.Integer64)); + foreach (var userRole in userRoles) + { + claims.Add(new Claim("role", userRole)); + } + + var identityClaims = new ClaimsIdentity(); + identityClaims.AddClaims(claims); + + return identityClaims; + } + + private string CodificarToken(ClaimsIdentity identityClaims) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var currentIssuer = + $"{_aspNetUser.ObterHttpContext().Request.Scheme}://{_aspNetUser.ObterHttpContext().Request.Host}"; + var key = _jwksService.GetCurrent(); + var token = tokenHandler.CreateToken(new SecurityTokenDescriptor + { + Issuer = currentIssuer, + Subject = identityClaims, + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = key + }); + + return tokenHandler.WriteToken(token); + } + + private UsuarioRespostaLoginViewModel ObterRespostaToken(string encodedToken, IdentityUser user, + IEnumerable claims, RefreshToken refreshToken) + { + return new UsuarioRespostaLoginViewModel + { + AccessToken = encodedToken, + RefreshToken = refreshToken.Token, + ExpiresIn = TimeSpan.FromHours(1).TotalSeconds, + UsuarioToken = new UsuarioTokenViewModel + { + Id = user.Id, + Email = user.Email, + Claims = claims.Select(c => new UsuarioClaimViewModel { Type = c.Type, Value = c.Value }) + } + }; + } + + private static long ToUnixEpochDate(DateTime date) + => (long)Math.Round((date.ToUniversalTime() - new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero)) + .TotalSeconds); + + private async Task GerarRefreshToken(string email) + { + var refreshToken = new RefreshToken + { + UserName = email, + ExpirationDate = DateTime.UtcNow.AddHours(_appTokenSettingsSettings.RefreshTokenExpiration) + }; + + _context.RefreshTokens.RemoveRange(_context.RefreshTokens.Where(u => u.UserName == email)); + await _context.RefreshTokens.AddAsync(refreshToken); + + await _context.SaveChangesAsync(); + + return refreshToken; + } + + public async Task ObterRefreshToken(Guid refreshToken) + { + var token = await _context.RefreshTokens.AsNoTracking() + .FirstOrDefaultAsync(u => u.Token == refreshToken); + + return token != null && token.ExpirationDate.ToLocalTime() > DateTime.Now + ? token + : null; + } + } +} \ No newline at end of file diff --git a/src/services/JSE.Identidade.API/appsettings.Development.json b/src/services/JSE.Identidade.API/appsettings.Development.json index b06a22d..809dc53 100644 --- a/src/services/JSE.Identidade.API/appsettings.Development.json +++ b/src/services/JSE.Identidade.API/appsettings.Development.json @@ -12,5 +12,10 @@ "MessageQueueConnection": { "MessageBus": "host=localhost:5672;publisherConfirms=true;timeout=10" + }, + + "AppTokenSettings": { + "RefreshTokenExpiration": 8 } + } diff --git a/src/web/JSE.WebApp.MVC/Controllers/IdentidadeController.cs b/src/web/JSE.WebApp.MVC/Controllers/IdentidadeController.cs index bdabc6b..33391af 100644 --- a/src/web/JSE.WebApp.MVC/Controllers/IdentidadeController.cs +++ b/src/web/JSE.WebApp.MVC/Controllers/IdentidadeController.cs @@ -1,19 +1,16 @@ -using JSE.WebApp.MVC.Models; +using JSE.WebApp.MVC.Controllers; +using JSE.WebApp.MVC.Models; using JSE.WebApp.MVC.Services; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -namespace JSE.WebApp.MVC.Controllers +namespace NSE.WebApp.MVC.Controllers { public class IdentidadeController : MainController { - private readonly IAutenticacaoService _autenticacaoService; - public IdentidadeController(IAutenticacaoService autenticacaoService) + public IdentidadeController( + IAutenticacaoService autenticacaoService) { _autenticacaoService = autenticacaoService; } @@ -27,18 +24,17 @@ public IActionResult Registro() [HttpPost] [Route("nova-conta")] - public async Task Registro(UsuarioRegistroViewModel usuarioRegistroViewModel) + public async Task Registro(UsuarioRegistroViewModel usuarioRegistro) { - if (!ModelState.IsValid) return View(usuarioRegistroViewModel); + if (!ModelState.IsValid) return View(usuarioRegistro); - var resposta = await _autenticacaoService.Registro(usuarioRegistroViewModel); + var resposta = await _autenticacaoService.Registro(usuarioRegistro); - if (ResponsePossuiErros(resposta.ResponseResult)) return View(usuarioRegistroViewModel); + if (ResponsePossuiErros(resposta.ResponseResult)) return View(usuarioRegistro); - await RealizarLogin(resposta); + await _autenticacaoService.RealizarLogin(resposta); return RedirectToAction("Index", "Catalogo"); - } [HttpGet] @@ -51,17 +47,16 @@ public IActionResult Login(string returnUrl = null) [HttpPost] [Route("login")] - public async Task Login(UsuarioLoginViewModel usuarioLoginViewModel, string returnUrl = null) + public async Task Login(UsuarioLoginViewModel usuarioLogin, string returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; + if (!ModelState.IsValid) return View(usuarioLogin); - if (!ModelState.IsValid) return View(usuarioLoginViewModel); + var resposta = await _autenticacaoService.Login(usuarioLogin); - var resposta = await _autenticacaoService.Login(usuarioLoginViewModel); + if (ResponsePossuiErros(resposta.ResponseResult)) return View(usuarioLogin); - if (ResponsePossuiErros(resposta.ResponseResult)) return View(usuarioLoginViewModel); - - await RealizarLogin(resposta); + await _autenticacaoService.RealizarLogin(resposta); if (string.IsNullOrEmpty(returnUrl)) return RedirectToAction("Index", "Catalogo"); @@ -72,35 +67,8 @@ public async Task Login(UsuarioLoginViewModel usuarioLoginViewMod [Route("sair")] public async Task Logout() { - await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + await _autenticacaoService.Logout(); return RedirectToAction("Index", "Catalogo"); } - - private async Task RealizarLogin(UsuarioRespostaLoginViewModel usuarioRespostaLoginViewModel) - { - var token = ObterTokenFormatado(usuarioRespostaLoginViewModel.AccessToken); - - var claims = new List(); - claims.Add(new Claim("JWT", usuarioRespostaLoginViewModel.AccessToken)); - claims.AddRange(token.Claims); - - var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); - - var authProperties = new AuthenticationProperties - { - ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(60), - IsPersistent = true - }; - - await HttpContext.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - new ClaimsPrincipal(claimsIdentity), - authProperties); - } - - private static JwtSecurityToken ObterTokenFormatado(string jwtToken) - { - return new JwtSecurityTokenHandler().ReadToken(jwtToken) as JwtSecurityToken; - } } -} +} \ No newline at end of file diff --git a/src/web/JSE.WebApp.MVC/Extensions/ExceptionMiddleware.cs b/src/web/JSE.WebApp.MVC/Extensions/ExceptionMiddleware.cs index 48c80d9..9664817 100644 --- a/src/web/JSE.WebApp.MVC/Extensions/ExceptionMiddleware.cs +++ b/src/web/JSE.WebApp.MVC/Extensions/ExceptionMiddleware.cs @@ -1,4 +1,5 @@ -using Polly.CircuitBreaker; +using JSE.WebApp.MVC.Services; +using Polly.CircuitBreaker; using Refit; using System.Net; @@ -7,14 +8,17 @@ namespace JSE.WebApp.MVC.Extensions public class ExceptionMiddleware { private readonly RequestDelegate _next; + private static IAutenticacaoService _autenticacaoService; public ExceptionMiddleware(RequestDelegate next) { _next = next; } - public async Task InvokeAsync(HttpContext httpContext) + public async Task InvokeAsync(HttpContext httpContext, IAutenticacaoService autenticacaoService) { + _autenticacaoService = autenticacaoService; + try { await _next(httpContext); @@ -41,6 +45,17 @@ private static void HandleRequestExceptionAsync(HttpContext context, HttpStatusC { if (statusCode == HttpStatusCode.Unauthorized) { + if(_autenticacaoService.TokenExpirado()) + { + if(_autenticacaoService.RefreshTokenValido().Result) + { + context.Response.Redirect(context.Request.Path); + return; + } + } + + _autenticacaoService.Logout(); + context.Response.Redirect($"/login?ReturnUrl={context.Request.Path}"); return; } diff --git a/src/web/JSE.WebApp.MVC/Models/UsuarioRespostaLoginViewModel.cs b/src/web/JSE.WebApp.MVC/Models/UsuarioRespostaLoginViewModel.cs index 81557bc..e317bc6 100644 --- a/src/web/JSE.WebApp.MVC/Models/UsuarioRespostaLoginViewModel.cs +++ b/src/web/JSE.WebApp.MVC/Models/UsuarioRespostaLoginViewModel.cs @@ -5,6 +5,7 @@ namespace JSE.WebApp.MVC.Models public class UsuarioRespostaLoginViewModel { public string AccessToken { get; set; } + public string RefreshToken { get; set; } public double ExpiresIn { get; set; } public UsuarioTokenViewModel UsuarioToken { get; set; } public ResponseResult ResponseResult { get; set; } diff --git a/src/web/JSE.WebApp.MVC/Services/AutenticacaoService.cs b/src/web/JSE.WebApp.MVC/Services/AutenticacaoService.cs index f699109..b09459a 100644 --- a/src/web/JSE.WebApp.MVC/Services/AutenticacaoService.cs +++ b/src/web/JSE.WebApp.MVC/Services/AutenticacaoService.cs @@ -1,20 +1,32 @@ using JSE.Core.Communication; +using JSE.WebAPI.Core.User; using JSE.WebApp.MVC.Extensions; using JSE.WebApp.MVC.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Options; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; namespace JSE.WebApp.MVC.Services { public class AutenticacaoService : Service, IAutenticacaoService { private readonly HttpClient _httpClient; + private readonly IAuthenticationService _authenticationService; + private readonly IAspNetUser _user; + public AutenticacaoService(HttpClient httpClient, - IOptions settings) + IOptions settings, + IAuthenticationService authenticationService, + IAspNetUser user) { httpClient.BaseAddress = new Uri(settings.Value.AutenticacaoUrl); - _httpClient = httpClient; + _httpClient = httpClient; + _authenticationService = authenticationService; + _user = user; } public async Task Login(UsuarioLoginViewModel usuarioLogin) @@ -50,5 +62,81 @@ public async Task Registro(UsuarioRegistroViewMod return await DeserializarObjetoResponse(response); } + + public async Task RealizarLogin(UsuarioRespostaLoginViewModel resposta) + { + var token = ObterTokenFormatado(resposta.AccessToken); + + var claims = new List(); + claims.Add(new Claim("JWT", resposta.AccessToken)); + claims.Add(new Claim("RefreshToken", resposta.RefreshToken)); + claims.AddRange(token.Claims); + + var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + + var authProperties = new AuthenticationProperties + { + ExpiresUtc = DateTimeOffset.UtcNow.AddHours(8), + IsPersistent = true + }; + + await _authenticationService.SignInAsync( + _user.ObterHttpContext(), + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(claimsIdentity), + authProperties); + } + + public async Task Logout() + { + await _authenticationService.SignOutAsync( + _user.ObterHttpContext(), + CookieAuthenticationDefaults.AuthenticationScheme, + null); + } + + public static JwtSecurityToken ObterTokenFormatado(string jwtToken) + { + return new JwtSecurityTokenHandler().ReadToken(jwtToken) as JwtSecurityToken; + } + + public async Task UtilizarRefreshToken(string refreshToken) + { + var refreshTokenContent = ObterConteudo(refreshToken); + + var response = await _httpClient.PostAsync("/api/identidade/refresh-token", refreshTokenContent); + + if (!TratarErrosResponse(response)) + { + return new UsuarioRespostaLoginViewModel + { + ResponseResult = await DeserializarObjetoResponse(response) + }; + } + + return await DeserializarObjetoResponse(response); + } + + public bool TokenExpirado() + { + var jwt = _user.ObterUserToken(); + if (jwt is null) return false; + + var token = ObterTokenFormatado(jwt); + return token.ValidTo.ToLocalTime() < DateTime.Now; + } + + public async Task RefreshTokenValido() + { + var resposta = await UtilizarRefreshToken(_user.ObterUserRefreshToken()); + + if (resposta.AccessToken != null && resposta.ResponseResult == null) + { + await RealizarLogin(resposta); + return true; + } + + return false; + } } } \ No newline at end of file diff --git a/src/web/JSE.WebApp.MVC/Services/IAutenticacaoService.cs b/src/web/JSE.WebApp.MVC/Services/IAutenticacaoService.cs index 8806b92..b84d953 100644 --- a/src/web/JSE.WebApp.MVC/Services/IAutenticacaoService.cs +++ b/src/web/JSE.WebApp.MVC/Services/IAutenticacaoService.cs @@ -5,7 +5,10 @@ namespace JSE.WebApp.MVC.Services public interface IAutenticacaoService { Task Login(UsuarioLoginViewModel usuarioLogin); - Task Registro(UsuarioRegistroViewModel usuarioRegistro); + Task RealizarLogin(UsuarioRespostaLoginViewModel resposta); + Task Logout(); + bool TokenExpirado(); + Task RefreshTokenValido(); } } \ No newline at end of file