diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml index e8608446..a341f7fc 100644 --- a/.github/workflows/dotnet-core.yml +++ b/.github/workflows/dotnet-core.yml @@ -16,7 +16,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Install dependencies run: dotnet restore - name: Build diff --git a/README.md b/README.md index df553620..ca6d4350 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ The Equinox Project is a open-source project written in .NET Core The goal of this project is implement the most common used technologies and share with the technical community the best way to develop great applications with .NET [![Build status](https://ci.appveyor.com/api/projects/status/rl2ja69994rt3ei6?svg=true)](https://ci.appveyor.com/project/EduardoPires/equinoxproject) -![.NET Core](https://github.com/EduardoPires/EquinoxProject/workflows/.NET%20Core/badge.svg) [![License](https://img.shields.io/github/license/eduardopires/equinoxproject.svg)](LICENSE) [![Issues open](https://img.shields.io/github/issues/eduardopires/equinoxproject.svg)](https://huboard.com/EduardoPires/EquinoxProject/) @@ -29,18 +28,17 @@ To know more about how to setup your enviroment visit the [Microsoft .NET Downlo ## Technologies implemented: -- ASP.NET 6.0 +- ASP.NET 8.0 - ASP.NET MVC Core - ASP.NET WebApi Core with JWT Bearer Authentication - ASP.NET Identity Core -- Entity Framework Core 6.0 +- Entity Framework Core 8.0 - .NET Core Native DI - AutoMapper - FluentValidator - MediatR - Swagger UI with JWT support - .NET DevPack -- .NET DevPack.Identity ## Architecture: @@ -56,6 +54,12 @@ To know more about how to setup your enviroment visit the [Microsoft .NET Downlo ## News +**v1.9 - 06/31/2024** +- Migrated for .NET 8.0 +- Full refactoring of Web and Api configuration +- Now all ASP.NET Identity configurations are inside the project, without external dependencies +- All dependencies is up to date + **v1.8 - 03/22/2022** - Migrated for .NET 6.0 - All dependencies is up to date diff --git a/src/Equinox.Application/Equinox.Application.csproj b/src/Equinox.Application/Equinox.Application.csproj index 3a5b59ed..b3df6541 100644 --- a/src/Equinox.Application/Equinox.Application.csproj +++ b/src/Equinox.Application/Equinox.Application.csproj @@ -1,12 +1,13 @@ - net6.0 + net8.0 + disable - - + + - + \ No newline at end of file diff --git a/src/Equinox.Domain.Core/Equinox.Domain.Core.csproj b/src/Equinox.Domain.Core/Equinox.Domain.Core.csproj index 8e37f651..f4bfdf36 100644 --- a/src/Equinox.Domain.Core/Equinox.Domain.Core.csproj +++ b/src/Equinox.Domain.Core/Equinox.Domain.Core.csproj @@ -1,6 +1,7 @@ - net6.0 + net8.0 + disable diff --git a/src/Equinox.Domain/Equinox.Domain.csproj b/src/Equinox.Domain/Equinox.Domain.csproj index 472d8dfb..f7a018b6 100644 --- a/src/Equinox.Domain/Equinox.Domain.csproj +++ b/src/Equinox.Domain/Equinox.Domain.csproj @@ -1,13 +1,14 @@ - net6.0 + net8.0 + disable - + diff --git a/src/Equinox.Infra.CrossCutting.Bus/Equinox.Infra.CrossCutting.Bus.csproj b/src/Equinox.Infra.CrossCutting.Bus/Equinox.Infra.CrossCutting.Bus.csproj index c1fad611..01715596 100644 --- a/src/Equinox.Infra.CrossCutting.Bus/Equinox.Infra.CrossCutting.Bus.csproj +++ b/src/Equinox.Infra.CrossCutting.Bus/Equinox.Infra.CrossCutting.Bus.csproj @@ -1,9 +1,10 @@ - net6.0 + net8.0 + disable - + diff --git a/src/Equinox.Infra.CrossCutting.Identity/API/AppJwtSettings.cs b/src/Equinox.Infra.CrossCutting.Identity/API/AppJwtSettings.cs new file mode 100644 index 00000000..26fb6627 --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/API/AppJwtSettings.cs @@ -0,0 +1,10 @@ +namespace Equinox.Infra.CrossCutting.Identity.API +{ + public class AppJwtSettings + { + public string SecretKey { get; set; } + public int Expiration { get; set; } = 1; + public string Issuer { get; set; } = "Equinox.Api"; + public string Audience { get; set; } = "Api"; + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/API/JwtBuilder.cs b/src/Equinox.Infra.CrossCutting.Identity/API/JwtBuilder.cs new file mode 100644 index 00000000..89840296 --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/API/JwtBuilder.cs @@ -0,0 +1,117 @@ +using Equinox.Infra.CrossCutting.Identity.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Text; + +namespace Equinox.Infra.CrossCutting.Identity.API +{ + public class JwtBuilder where TIdentityUser : IdentityUser where TKey : IEquatable + { + private UserManager _userManager; + private AppJwtSettings _appJwtSettings; + private TIdentityUser _user; + private ICollection _userClaims; + private ICollection _jwtClaims; + private ClaimsIdentity _identityClaims; + + public JwtBuilder WithUserManager(UserManager userManager) + { + _userManager = userManager ?? throw new ArgumentException(nameof(userManager)); + return this; + } + + public JwtBuilder WithJwtSettings(AppJwtSettings appJwtSettings) + { + _appJwtSettings = appJwtSettings ?? throw new ArgumentException(nameof(appJwtSettings)); + return this; + } + + public JwtBuilder WithEmail(string email) + { + if (string.IsNullOrEmpty(email)) throw new ArgumentException(nameof(email)); + + _user = _userManager.FindByEmailAsync(email).Result; + _userClaims = new List(); + _jwtClaims = new List(); + _identityClaims = new ClaimsIdentity(); + + return this; + } + + public JwtBuilder WithJwtClaims() + { + _jwtClaims.Add(new Claim(JwtRegisteredClaimNames.Sub, _user.Id.ToString())); + _jwtClaims.Add(new Claim(JwtRegisteredClaimNames.Email, _user.Email)); + _jwtClaims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())); + _jwtClaims.Add(new Claim(JwtRegisteredClaimNames.Nbf, ToUnixEpochDate(DateTime.UtcNow).ToString())); + _jwtClaims.Add(new Claim(JwtRegisteredClaimNames.Iat, ToUnixEpochDate(DateTime.UtcNow).ToString(), ClaimValueTypes.Integer64)); + + _identityClaims.AddClaims(_jwtClaims); + + return this; + } + + public JwtBuilder WithUserClaims() + { + _userClaims = _userManager.GetClaimsAsync(_user).Result; + _identityClaims.AddClaims(_userClaims); + + return this; + } + + public JwtBuilder WithUserRoles() + { + var userRoles = _userManager.GetRolesAsync(_user).Result; + userRoles.ToList().ForEach(r => _identityClaims.AddClaim(new Claim("role", r))); + + return this; + } + + public string BuildToken() + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.ASCII.GetBytes(_appJwtSettings.SecretKey); + var token = tokenHandler.CreateToken(new SecurityTokenDescriptor + { + Issuer = _appJwtSettings.Issuer, + Audience = _appJwtSettings.Audience, + Subject = _identityClaims, + Expires = DateTime.UtcNow.AddHours(_appJwtSettings.Expiration), + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature) + }); + + return tokenHandler.WriteToken(token); + } + + public UserResponse BuildUserResponse() + { + var user = new UserResponse + { + AccessToken = BuildToken(), + ExpiresIn = TimeSpan.FromHours(_appJwtSettings.Expiration).TotalSeconds, + UserToken = new UserToken + { + Id = _user.Id, + Email = _user.Email, + Claims = _userClaims.Select(c => new UserClaim { Type = c.Type, Value = c.Value }) + } + }; + + return user; + } + + private static long ToUnixEpochDate(DateTime date) + => (long)Math.Round((date.ToUniversalTime() - new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero)) + .TotalSeconds); + } + + public class JwtBuilder : JwtBuilder where TIdentityUser : IdentityUser { } + + public sealed class JwtBuilder : JwtBuilder { } +} diff --git a/src/Equinox.Infra.CrossCutting.Identity/ApiIdentityConfig.cs b/src/Equinox.Infra.CrossCutting.Identity/ApiIdentityConfig.cs deleted file mode 100644 index 99fda35b..00000000 --- a/src/Equinox.Infra.CrossCutting.Identity/ApiIdentityConfig.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using NetDevPack.Identity; -using NetDevPack.Identity.Jwt; - -namespace Equinox.Infra.CrossCutting.Identity -{ - public static class ApiIdentityConfig - { - public static void AddApiIdentityConfiguration(this IServiceCollection services, IConfiguration configuration) - { - // Default EF Context for Identity (inside of the NetDevPack.Identity) - services.AddIdentityEntityFrameworkContextConfiguration(options => - options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"), - b => b.MigrationsAssembly("Equinox.Infra.CrossCutting.Identity"))); - - // Default Identity configuration from NetDevPack.Identity - services.AddIdentityConfiguration(); - - // Default JWT configuration from NetDevPack.Identity - services.AddJwtConfiguration(configuration, "AppSettings"); - } - } -} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/Authorization/CustomAuthorizationValidation.cs b/src/Equinox.Infra.CrossCutting.Identity/Authorization/CustomAuthorizationValidation.cs new file mode 100644 index 00000000..3c7be705 --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/Authorization/CustomAuthorizationValidation.cs @@ -0,0 +1,17 @@ +using System.Linq; +using Microsoft.AspNetCore.Http; + +namespace Equinox.Infra.CrossCutting.Identity.Authorization +{ + public static class CustomAuthorizationValidation + { + public static bool UserHasValidClaim(HttpContext context, string claimName, string claimValue) + { + return context.User.Identity.IsAuthenticated && + context.User.Claims.Any(c => + c.Type == claimName && + c.Value.Split(',').Select(v => v.Trim()).Contains(claimValue)); + } + + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/Authorization/CustomAuthorizeAttribute.cs b/src/Equinox.Infra.CrossCutting.Identity/Authorization/CustomAuthorizeAttribute.cs new file mode 100644 index 00000000..7e946872 --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/Authorization/CustomAuthorizeAttribute.cs @@ -0,0 +1,13 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc; + +namespace Equinox.Infra.CrossCutting.Identity.Authorization +{ + public class CustomAuthorizeAttribute : TypeFilterAttribute + { + public CustomAuthorizeAttribute(string claimName, string claimValue) : base(typeof(RequerimentClaimFilter)) + { + Arguments = new object[] { new Claim(claimName, claimValue) }; + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/Authorization/RequerimentClaimFilter.cs b/src/Equinox.Infra.CrossCutting.Identity/Authorization/RequerimentClaimFilter.cs new file mode 100644 index 00000000..680606cf --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/Authorization/RequerimentClaimFilter.cs @@ -0,0 +1,30 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Equinox.Infra.CrossCutting.Identity.Authorization +{ + internal class RequerimentClaimFilter : IAuthorizationFilter + { + private readonly Claim _claim; + + public RequerimentClaimFilter(Claim claim) + { + _claim = claim; + } + + public void OnAuthorization(AuthorizationFilterContext context) + { + if (!context.HttpContext.User.Identity.IsAuthenticated) + { + context.Result = new StatusCodeResult(401); + return; + } + + if (!CustomAuthorizationValidation.UserHasValidClaim(context.HttpContext, _claim.Type, _claim.Value)) + { + context.Result = new StatusCodeResult(403); + } + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/Configuration/AspNetIdentityConfig.cs b/src/Equinox.Infra.CrossCutting.Identity/Configuration/AspNetIdentityConfig.cs new file mode 100644 index 00000000..16b342ad --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/Configuration/AspNetIdentityConfig.cs @@ -0,0 +1,124 @@ +using Equinox.Infra.CrossCutting.Identity.API; +using Equinox.Infra.CrossCutting.Identity.Data; +using Equinox.Infra.CrossCutting.Identity.User; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Text; + +namespace Equinox.Infra.CrossCutting.Identity.Configuration +{ + public static class AspNetIdentityConfig + { + public static WebApplicationBuilder AddApiIdentityConfiguration(this WebApplicationBuilder builder) + { + builder.AddIdentityDbContext() + .AddIdentityApiSupport() + .AddJwtSupport() + .AddAspNetUserSupport(); + + return builder; + } + + public static WebApplicationBuilder AddWebIdentityConfiguration(this WebApplicationBuilder builder) + { + builder.AddIdentityDbContext() + .AddIdentityWebUISupport() + .AddAspNetUserSupport() + .AddSocialAuthenticationSupport(); + + return builder; + } + + private static WebApplicationBuilder AddIdentityDbContext(this WebApplicationBuilder builder) + { + builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"), + b => b.MigrationsAssembly("Equinox.Infra.CrossCutting.Identity.Data"))); + + return builder; + } + + private static WebApplicationBuilder AddIdentityApiSupport(this WebApplicationBuilder builder) + { + builder.Services.AddIdentityApiEndpoints() + .AddRoles() + .AddEntityFrameworkStores() + .AddSignInManager() + .AddRoleManager>() + .AddDefaultTokenProviders(); + + return builder; + } + + private static WebApplicationBuilder AddIdentityWebUISupport(this WebApplicationBuilder builder) + { + builder.Services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders() + .AddDefaultUI(); + + return builder; + } + + private static WebApplicationBuilder AddJwtSupport(this WebApplicationBuilder builder) + { + var appSettingsSection = builder.Configuration.GetSection("AppSettings"); + builder.Services.Configure(appSettingsSection); + + var appSettings = appSettingsSection.Get(); + var key = Encoding.ASCII.GetBytes(appSettings.SecretKey); + + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.RequireHttpsMetadata = true; + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = true, + ValidateAudience = true, + ValidAudience = appSettings.Audience, + ValidIssuer = appSettings.Issuer + }; + }); + + return builder; + } + + public static WebApplicationBuilder AddAspNetUserSupport(this WebApplicationBuilder builder) + { + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + + return builder; + } + + public static WebApplicationBuilder AddSocialAuthenticationSupport(this WebApplicationBuilder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + builder.Services.AddAuthentication() + .AddFacebook(o => + { + o.AppId = builder.Configuration["Authentication:Facebook:AppId"]; + o.AppSecret = builder.Configuration["Authentication:Facebook:AppSecret"]; + }) + .AddGoogle(googleOptions => + { + googleOptions.ClientId = builder.Configuration["Authentication:Google:ClientId"]; + googleOptions.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"]; + }); + + return builder; + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/Data/EquinoxIdentityContext.cs b/src/Equinox.Infra.CrossCutting.Identity/Data/EquinoxIdentityContext.cs new file mode 100644 index 00000000..eacd8e71 --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/Data/EquinoxIdentityContext.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Equinox.Infra.CrossCutting.Identity.Data +{ + public class EquinoxIdentityContext : IdentityDbContext + { + public EquinoxIdentityContext(DbContextOptions options) : base(options) { } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/Equinox.Infra.CrossCutting.Identity.csproj b/src/Equinox.Infra.CrossCutting.Identity/Equinox.Infra.CrossCutting.Identity.csproj index da9a8a5b..fce7630a 100644 --- a/src/Equinox.Infra.CrossCutting.Identity/Equinox.Infra.CrossCutting.Identity.csproj +++ b/src/Equinox.Infra.CrossCutting.Identity/Equinox.Infra.CrossCutting.Identity.csproj @@ -1,13 +1,25 @@ - + - net6.0 + net8.0 + disable - - - + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/Extensions/ClaimsPrincipalExtensions.cs b/src/Equinox.Infra.CrossCutting.Identity/Extensions/ClaimsPrincipalExtensions.cs new file mode 100644 index 00000000..3f3182b6 --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/Extensions/ClaimsPrincipalExtensions.cs @@ -0,0 +1,62 @@ +using System.Security.Claims; +using System; +using System.IdentityModel.Tokens.Jwt; + +namespace Equinox.Infra.CrossCutting.Identity.Extensions +{ + public static class ClaimsPrincipalExtensions + { + public static string GetUserId(this ClaimsPrincipal principal) + { + if (principal == null) + { + throw new ArgumentException(nameof(principal)); + } + + var claim = principal.FindFirst(JwtRegisteredClaimNames.Sub); + if (claim is null) + claim = principal.FindFirst(ClaimTypes.NameIdentifier); + + return claim?.Value; + } + + public static string GetUserEmail(this ClaimsPrincipal principal) + { + if (principal == null) + { + throw new ArgumentException(nameof(principal)); + } + var claim = principal.FindFirst(JwtRegisteredClaimNames.Sub); + if (claim is null) + claim = principal.FindFirst(ClaimTypes.Email); + + return claim?.Value; + } + public static string GetUserId(this ClaimsIdentity principal) + { + if (principal == null) + { + throw new ArgumentException(nameof(principal)); + } + + var claim = principal.FindFirst(JwtRegisteredClaimNames.Sub); + if (claim is null) + claim = principal.FindFirst(ClaimTypes.NameIdentifier); + + return claim?.Value; + } + + public static string GetUserEmail(this ClaimsIdentity principal) + { + if (principal == null) + { + throw new ArgumentException(nameof(principal)); + } + var claim = principal.FindFirst(JwtRegisteredClaimNames.Sub); + if (claim is null) + claim = principal.FindFirst(ClaimTypes.Email); + + return claim?.Value; + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/Models/LoginUser.cs b/src/Equinox.Infra.CrossCutting.Identity/Models/LoginUser.cs new file mode 100644 index 00000000..349d64e9 --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/Models/LoginUser.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Equinox.Infra.CrossCutting.Identity.Models +{ + public class LoginUser + { + [Required] + [EmailAddress] + public string Email { get; set; } + + [Required] + [StringLength(100, MinimumLength = 6)] + public string Password { get; set; } + } +} diff --git a/src/Equinox.Infra.CrossCutting.Identity/Models/RegisterUser.cs b/src/Equinox.Infra.CrossCutting.Identity/Models/RegisterUser.cs new file mode 100644 index 00000000..bb0a12e0 --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/Models/RegisterUser.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Equinox.Infra.CrossCutting.Identity.Models +{ + public class RegisterUser + { + [Required] + [EmailAddress] + public string Email { get; set; } + + [Required] + [StringLength(100, MinimumLength = 6)] + public string Password { get; set; } + + [Compare("Password")] + public string ConfirmPassword { get; set; } + } +} diff --git a/src/Equinox.Infra.CrossCutting.Identity/Models/UserClaim.cs b/src/Equinox.Infra.CrossCutting.Identity/Models/UserClaim.cs new file mode 100644 index 00000000..c37ab8e6 --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/Models/UserClaim.cs @@ -0,0 +1,8 @@ +namespace Equinox.Infra.CrossCutting.Identity.Models +{ + public class UserClaim + { + public string Value { get; set; } + public string Type { get; set; } + } +} diff --git a/src/Equinox.Infra.CrossCutting.Identity/Models/UserResponse.cs b/src/Equinox.Infra.CrossCutting.Identity/Models/UserResponse.cs new file mode 100644 index 00000000..60c03a4d --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/Models/UserResponse.cs @@ -0,0 +1,10 @@ +namespace Equinox.Infra.CrossCutting.Identity.Models +{ + public class UserResponse + { + public string AccessToken { get; set; } + public double ExpiresIn { get; set; } + public UserToken UserToken { get; set; } + public string RefreshToken { get; set; } + } +} diff --git a/src/Equinox.Infra.CrossCutting.Identity/Models/UserToken.cs b/src/Equinox.Infra.CrossCutting.Identity/Models/UserToken.cs new file mode 100644 index 00000000..2897becc --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/Models/UserToken.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Equinox.Infra.CrossCutting.Identity.Models +{ + public class UserToken + { + public dynamic Id { get; set; } + public string Email { get; set; } + public IEnumerable Claims { get; set; } + } +} diff --git a/src/Equinox.Infra.CrossCutting.Identity/User/AspNetUser.cs b/src/Equinox.Infra.CrossCutting.Identity/User/AspNetUser.cs new file mode 100644 index 00000000..b70d5c7f --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/User/AspNetUser.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using Equinox.Infra.CrossCutting.Identity.Extensions; +using Microsoft.AspNetCore.Http; + +namespace Equinox.Infra.CrossCutting.Identity.User +{ + public class AspNetUser : IAspNetUser + { + private readonly IHttpContextAccessor _accessor; + + public AspNetUser(IHttpContextAccessor accessor) + { + _accessor = accessor; + } + + public string Name => _accessor.HttpContext.User.Identity.Name; + + public Guid GetUserId() + { + return IsAutenticated() ? Guid.Parse(_accessor.HttpContext.User.GetUserId()) : Guid.Empty; + } + + public string GetUserEmail() + { + return IsAutenticated() ? _accessor.HttpContext.User.GetUserEmail() : ""; + } + + public bool IsAutenticated() + { + return _accessor.HttpContext.User.Identity.IsAuthenticated; + } + + public bool IsInRole(string role) + { + return _accessor.HttpContext.User.IsInRole(role); + } + + public IEnumerable GetUserClaims() + { + return _accessor.HttpContext.User.Claims; + } + + public HttpContext GetHttpContext() + { + return _accessor.HttpContext; + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/User/IAspNetUser.cs b/src/Equinox.Infra.CrossCutting.Identity/User/IAspNetUser.cs new file mode 100644 index 00000000..59a1087a --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/User/IAspNetUser.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; + +namespace Equinox.Infra.CrossCutting.Identity.User +{ + public interface IAspNetUser + { + string Name { get; } + Guid GetUserId(); + string GetUserEmail(); + bool IsAutenticated(); + bool IsInRole(string role); + IEnumerable GetUserClaims(); + HttpContext GetHttpContext(); + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/WebAppIdentityConfig.cs b/src/Equinox.Infra.CrossCutting.Identity/WebAppIdentityConfig.cs deleted file mode 100644 index e2ca0b7b..00000000 --- a/src/Equinox.Infra.CrossCutting.Identity/WebAppIdentityConfig.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using NetDevPack.Identity; - -namespace Equinox.Infra.CrossCutting.Identity -{ - public static class WebAppIdentityConfig - { - public static void AddWebAppIdentityConfiguration(this IServiceCollection services, IConfiguration configuration) - { - // Default EF Context for Identity (inside of the NetDevPack.Identity) - services.AddIdentityEntityFrameworkContextConfiguration(options => - SqlServerDbContextOptionsExtensions.UseSqlServer(options, configuration.GetConnectionString("DefaultConnection"), - b => b.MigrationsAssembly("Equinox.Infra.CrossCutting.Identity"))); - - // Default Identity configuration from NetDevPack.Identity - services.AddIdentityConfiguration(); - } - } -} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.IoC/Equinox.Infra.CrossCutting.IoC.csproj b/src/Equinox.Infra.CrossCutting.IoC/Equinox.Infra.CrossCutting.IoC.csproj index 6de87bcb..e7606282 100644 --- a/src/Equinox.Infra.CrossCutting.IoC/Equinox.Infra.CrossCutting.IoC.csproj +++ b/src/Equinox.Infra.CrossCutting.IoC/Equinox.Infra.CrossCutting.IoC.csproj @@ -1,13 +1,13 @@ - net6.0 + net8.0 + disable - diff --git a/src/Equinox.Infra.CrossCutting.IoC/NativeInjectorBootStrapper.cs b/src/Equinox.Infra.CrossCutting.IoC/NativeInjectorBootStrapper.cs index 885f9a7d..2f5dd1a5 100644 --- a/src/Equinox.Infra.CrossCutting.IoC/NativeInjectorBootStrapper.cs +++ b/src/Equinox.Infra.CrossCutting.IoC/NativeInjectorBootStrapper.cs @@ -11,6 +11,7 @@ using Equinox.Infra.Data.Repository.EventSourcing; using FluentValidation.Results; using MediatR; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using NetDevPack.Mediator; @@ -18,32 +19,32 @@ namespace Equinox.Infra.CrossCutting.IoC { public static class NativeInjectorBootStrapper { - public static void RegisterServices(IServiceCollection services) + public static void RegisterServices(WebApplicationBuilder builder) { // Domain Bus (Mediator) - services.AddScoped(); + builder.Services.AddScoped(); // Application - services.AddScoped(); + builder.Services.AddScoped(); // Domain - Events - services.AddScoped, CustomerEventHandler>(); - services.AddScoped, CustomerEventHandler>(); - services.AddScoped, CustomerEventHandler>(); + builder.Services.AddScoped, CustomerEventHandler>(); + builder.Services.AddScoped, CustomerEventHandler>(); + builder.Services.AddScoped, CustomerEventHandler>(); // Domain - Commands - services.AddScoped, CustomerCommandHandler>(); - services.AddScoped, CustomerCommandHandler>(); - services.AddScoped, CustomerCommandHandler>(); + builder.Services.AddScoped, CustomerCommandHandler>(); + builder.Services.AddScoped, CustomerCommandHandler>(); + builder.Services.AddScoped, CustomerCommandHandler>(); // Infra - Data - services.AddScoped(); - services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); // Infra - Data EventSourcing - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); } } } \ No newline at end of file diff --git a/src/Equinox.Infra.Data/Equinox.Infra.Data.csproj b/src/Equinox.Infra.Data/Equinox.Infra.Data.csproj index 1860cd2c..cd1b3a85 100644 --- a/src/Equinox.Infra.Data/Equinox.Infra.Data.csproj +++ b/src/Equinox.Infra.Data/Equinox.Infra.Data.csproj @@ -1,15 +1,19 @@ - net6.0 + net8.0 + disable - - - - + + + + + + + \ No newline at end of file diff --git a/src/Equinox.Infra.Data/EventSourcing/SqlEventStore.cs b/src/Equinox.Infra.Data/EventSourcing/SqlEventStore.cs index dac91b12..90c2deeb 100644 --- a/src/Equinox.Infra.Data/EventSourcing/SqlEventStore.cs +++ b/src/Equinox.Infra.Data/EventSourcing/SqlEventStore.cs @@ -1,6 +1,6 @@ using Equinox.Domain.Core.Events; +using Equinox.Infra.CrossCutting.Identity.User; using Equinox.Infra.Data.Repository.EventSourcing; -using NetDevPack.Identity.User; using NetDevPack.Messaging; using Newtonsoft.Json; diff --git a/src/Equinox.Services.Api/Configurations/ApiConfig.cs b/src/Equinox.Services.Api/Configurations/ApiConfig.cs new file mode 100644 index 00000000..80fba996 --- /dev/null +++ b/src/Equinox.Services.Api/Configurations/ApiConfig.cs @@ -0,0 +1,20 @@ +namespace Equinox.Services.Api.Configurations +{ + public static class ApiConfig + { + public static WebApplicationBuilder AddApiConfiguration(this WebApplicationBuilder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + builder.Configuration + .SetBasePath(builder.Environment.ContentRootPath) + .AddJsonFile("appsettings.json", true, true) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true) + .AddEnvironmentVariables(); + + builder.Services.AddControllers(); + + return builder; + } + } +} \ No newline at end of file diff --git a/src/Equinox.Services.Api/Configurations/AutoMapperConfig.cs b/src/Equinox.Services.Api/Configurations/AutoMapperConfig.cs index 1d7efcba..15126f77 100644 --- a/src/Equinox.Services.Api/Configurations/AutoMapperConfig.cs +++ b/src/Equinox.Services.Api/Configurations/AutoMapperConfig.cs @@ -1,16 +1,16 @@ -using System; -using Equinox.Application.AutoMapper; -using Microsoft.Extensions.DependencyInjection; +using Equinox.Application.AutoMapper; namespace Equinox.Services.Api.Configurations { public static class AutoMapperConfig { - public static void AddAutoMapperConfiguration(this IServiceCollection services) + public static WebApplicationBuilder AddAutoMapperConfiguration(this WebApplicationBuilder builder) { - if (services == null) throw new ArgumentNullException(nameof(services)); + if (builder == null) throw new ArgumentNullException(nameof(builder)); - services.AddAutoMapper(typeof(DomainToViewModelMappingProfile), typeof(ViewModelToDomainMappingProfile)); + builder.Services.AddAutoMapper(typeof(DomainToViewModelMappingProfile), typeof(ViewModelToDomainMappingProfile)); + + return builder; } } } \ No newline at end of file diff --git a/src/Equinox.Services.Api/Configurations/DatabaseConfig.cs b/src/Equinox.Services.Api/Configurations/DatabaseConfig.cs index adbafe60..a6eb3071 100644 --- a/src/Equinox.Services.Api/Configurations/DatabaseConfig.cs +++ b/src/Equinox.Services.Api/Configurations/DatabaseConfig.cs @@ -1,22 +1,21 @@ -using System; -using Equinox.Infra.Data.Context; +using Equinox.Infra.Data.Context; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; namespace Equinox.Services.Api.Configurations { public static class DatabaseConfig { - public static void AddDatabaseConfiguration(this IServiceCollection services, IConfiguration configuration) + public static WebApplicationBuilder AddDatabaseConfiguration(this WebApplicationBuilder builder) { - if (services == null) throw new ArgumentNullException(nameof(services)); + if (builder == null) throw new ArgumentNullException(nameof(builder)); - services.AddDbContext(options => - options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); + builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); - services.AddDbContext(options => - options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); + builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); + + return builder; } } } \ No newline at end of file diff --git a/src/Equinox.Services.Api/Configurations/DependencyInjectionConfig.cs b/src/Equinox.Services.Api/Configurations/DependencyInjectionConfig.cs index 9b54a449..39f0d026 100644 --- a/src/Equinox.Services.Api/Configurations/DependencyInjectionConfig.cs +++ b/src/Equinox.Services.Api/Configurations/DependencyInjectionConfig.cs @@ -1,16 +1,16 @@ -using System; -using Equinox.Infra.CrossCutting.IoC; -using Microsoft.Extensions.DependencyInjection; +using Equinox.Infra.CrossCutting.IoC; namespace Equinox.Services.Api.Configurations { public static class DependencyInjectionConfig { - public static void AddDependencyInjectionConfiguration(this IServiceCollection services) + public static WebApplicationBuilder AddDependencyInjectionConfiguration(this WebApplicationBuilder builder) { - if (services == null) throw new ArgumentNullException(nameof(services)); + if (builder == null) throw new ArgumentNullException(nameof(builder)); - NativeInjectorBootStrapper.RegisterServices(services); + NativeInjectorBootStrapper.RegisterServices(builder); + + return builder; } } } \ No newline at end of file diff --git a/src/Equinox.Services.Api/Configurations/MediatRConfig.cs b/src/Equinox.Services.Api/Configurations/MediatRConfig.cs new file mode 100644 index 00000000..aff75658 --- /dev/null +++ b/src/Equinox.Services.Api/Configurations/MediatRConfig.cs @@ -0,0 +1,16 @@ +using System.Reflection; + +namespace Equinox.Services.Api.Configurations +{ + public static class MediatRConfig + { + public static WebApplicationBuilder AddMediatRConfiguration(this WebApplicationBuilder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); + + return builder; + } + } +} \ No newline at end of file diff --git a/src/Equinox.Services.Api/Configurations/SwaggerConfig.cs b/src/Equinox.Services.Api/Configurations/SwaggerConfig.cs index 60fd1cdd..d52185cc 100644 --- a/src/Equinox.Services.Api/Configurations/SwaggerConfig.cs +++ b/src/Equinox.Services.Api/Configurations/SwaggerConfig.cs @@ -1,17 +1,16 @@ -using System; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Models; namespace Equinox.Services.Api.Configurations { public static class SwaggerConfig { - public static void AddSwaggerConfiguration(this IServiceCollection services) + public static WebApplicationBuilder AddSwaggerConfiguration(this WebApplicationBuilder builder) { - if (services == null) throw new ArgumentNullException(nameof(services)); + if (builder == null) throw new ArgumentNullException(nameof(builder)); - services.AddSwaggerGen(s => + builder.Services.AddEndpointsApiExplorer(); + + builder.Services.AddSwaggerGen(s => { s.SwaggerDoc("v1", new OpenApiInfo { @@ -47,10 +46,42 @@ public static void AddSwaggerConfiguration(this IServiceCollection services) } }); + // Excluding ASP.NET Identity endpoints + s.DocInclusionPredicate((docName, apiDesc) => + { + var relativePath = apiDesc.RelativePath; + + // List of avoid patches + var identityEndpoints = new[] + { + "register", + "manage", + "refresh", + "login", + "confirmEmail", + "resendConfirmationEmail", + "forgotPassword", + "resetPassword" + }; + + // Validating if the endpoint is avoided + foreach (var endpoint in identityEndpoints) + { + if (relativePath.Contains(endpoint, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + }); + }); + + return builder; } - public static void UseSwaggerSetup(this IApplicationBuilder app) + public static IApplicationBuilder UseSwaggerSetup(this IApplicationBuilder app) { if (app == null) throw new ArgumentNullException(nameof(app)); @@ -59,6 +90,8 @@ public static void UseSwaggerSetup(this IApplicationBuilder app) { c.SwaggerEndpoint("/swagger/v1/swagger.json", "v1"); }); + + return app; } } } \ No newline at end of file diff --git a/src/Equinox.Services.Api/Controllers/AccountController.cs b/src/Equinox.Services.Api/Controllers/AccountController.cs index 07416c97..6617d497 100644 --- a/src/Equinox.Services.Api/Controllers/AccountController.cs +++ b/src/Equinox.Services.Api/Controllers/AccountController.cs @@ -1,13 +1,12 @@ -using System.Threading.Tasks; +using Equinox.Infra.CrossCutting.Identity.API; +using Equinox.Infra.CrossCutting.Identity.Models; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using NetDevPack.Identity.Jwt; -using NetDevPack.Identity.Model; namespace Equinox.Services.Api.Controllers { - [Route("api/[controller]")] + [Route("account")] [ApiController] public class AccountController : ApiController { @@ -26,7 +25,7 @@ public AccountController( } [HttpPost] - [Route("register")] + [Route("new")] public async Task Register(RegisterUser registerUser) { if (!ModelState.IsValid) return CustomResponse(ModelState); @@ -54,7 +53,7 @@ public async Task Register(RegisterUser registerUser) } [HttpPost] - [Route("login")] + [Route("enter")] public async Task Login(LoginUser loginUser) { if (!ModelState.IsValid) return CustomResponse(ModelState); diff --git a/src/Equinox.Services.Api/Controllers/ApiController.cs b/src/Equinox.Services.Api/Controllers/ApiController.cs index 6f518ae2..8266fbf9 100644 --- a/src/Equinox.Services.Api/Controllers/ApiController.cs +++ b/src/Equinox.Services.Api/Controllers/ApiController.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Linq; -using FluentValidation.Results; +using FluentValidation.Results; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; diff --git a/src/Equinox.Services.Api/Controllers/CustomerController.cs b/src/Equinox.Services.Api/Controllers/CustomerController.cs index cc32d374..e900dc17 100644 --- a/src/Equinox.Services.Api/Controllers/CustomerController.cs +++ b/src/Equinox.Services.Api/Controllers/CustomerController.cs @@ -1,12 +1,9 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; using Equinox.Application.EventSourcedNormalizers; using Equinox.Application.Interfaces; using Equinox.Application.ViewModels; +using Equinox.Infra.CrossCutting.Identity.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using NetDevPack.Identity.Authorization; namespace Equinox.Services.Api.Controllers { @@ -21,42 +18,42 @@ public CustomerController(ICustomerAppService customerAppService) } [AllowAnonymous] - [HttpGet("customer-management")] + [HttpGet("customer")] public async Task> Get() { return await _customerAppService.GetAll(); } [AllowAnonymous] - [HttpGet("customer-management/{id:guid}")] + [HttpGet("customer/{id:guid}")] public async Task Get(Guid id) { return await _customerAppService.GetById(id); } [CustomAuthorize("Customers", "Write")] - [HttpPost("customer-management")] + [HttpPost("customer")] public async Task Post([FromBody]CustomerViewModel customerViewModel) { return !ModelState.IsValid ? CustomResponse(ModelState) : CustomResponse(await _customerAppService.Register(customerViewModel)); } [CustomAuthorize("Customers", "Write")] - [HttpPut("customer-management")] + [HttpPut("customer")] public async Task Put([FromBody]CustomerViewModel customerViewModel) { return !ModelState.IsValid ? CustomResponse(ModelState) : CustomResponse(await _customerAppService.Update(customerViewModel)); } [CustomAuthorize("Customers", "Remove")] - [HttpDelete("customer-management")] + [HttpDelete("customer")] public async Task Delete(Guid id) { return CustomResponse(await _customerAppService.Remove(id)); } [AllowAnonymous] - [HttpGet("customer-management/history/{id:guid}")] + [HttpGet("customer/history/{id:guid}")] public async Task> History(Guid id) { return await _customerAppService.GetAllHistory(id); diff --git a/src/Equinox.Services.Api/Equinox.Services.Api.csproj b/src/Equinox.Services.Api/Equinox.Services.Api.csproj index c632b61f..4296d3e5 100644 --- a/src/Equinox.Services.Api/Equinox.Services.Api.csproj +++ b/src/Equinox.Services.Api/Equinox.Services.Api.csproj @@ -1,29 +1,30 @@ - net6.0 + net8.0 + disable b543be42-f7ab-48b6-b633-72d6fb529fb7 enable Linux ..\.. - - - - - - - + + + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + + + \ No newline at end of file diff --git a/src/Equinox.Services.Api/Program.cs b/src/Equinox.Services.Api/Program.cs index d801f160..146bb824 100644 --- a/src/Equinox.Services.Api/Program.cs +++ b/src/Equinox.Services.Api/Program.cs @@ -1,69 +1,33 @@ -using Equinox.Infra.CrossCutting.Identity; +using Equinox.Infra.CrossCutting.Identity.Configuration; using Equinox.Services.Api.Configurations; -using MediatR; -using NetDevPack.Identity; -using NetDevPack.Identity.User; +using Microsoft.AspNetCore.Identity; var builder = WebApplication.CreateBuilder(args); -builder.Configuration - .SetBasePath(builder.Environment.ContentRootPath) - .AddJsonFile("appsettings.json", true, true) - .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true) - .AddEnvironmentVariables(); - -// ConfigureServices - -// WebAPI Config -builder.Services.AddControllers(); - -// Setting DBContexts -builder.Services.AddDatabaseConfiguration(builder.Configuration); - -// ASP.NET Identity Settings & JWT -builder.Services.AddApiIdentityConfiguration(builder.Configuration); - -// Interactive AspNetUser (logged in) -// NetDevPack.Identity dependency -builder.Services.AddAspNetUserConfiguration(); - -// AutoMapper Settings -builder.Services.AddAutoMapperConfiguration(); - -// Swagger Config -builder.Services.AddSwaggerConfiguration(); - -// Adding MediatR for Domain Events and Notifications -builder.Services.AddMediatR(AppDomain.CurrentDomain.GetAssemblies()); - -// .NET Native DI Abstraction -builder.Services.AddDependencyInjectionConfiguration(); +// Configure Services +builder.AddApiConfiguration() // Api Configurations + .AddDatabaseConfiguration() // Setting DBContexts + .AddApiIdentityConfiguration() // ASP.NET Identity Settings & JWT + .AddAutoMapperConfiguration() // AutoMapper Settings + .AddSwaggerConfiguration() // Swagger Config + .AddMediatRConfiguration() // Adding MediatR for Domain Events and Notifications + .AddDependencyInjectionConfiguration(); // DotNet Native DI Abstraction var app = builder.Build(); // Configure - -if (app.Environment.IsDevelopment()) -{ - app.UseDeveloperExceptionPage(); -} - -app.UseHttpsRedirection(); - -app.UseRouting(); - -app.UseCors(c => -{ - c.AllowAnyHeader(); - c.AllowAnyMethod(); - c.AllowAnyOrigin(); -}); - -// NetDevPack.Identity dependency -app.UseAuthConfiguration(); +app.UseHttpsRedirection() + .UseCors(c => + { + c.AllowAnyHeader(); + c.AllowAnyMethod(); + c.AllowAnyOrigin(); + }) + .UseAuthentication() + .UseAuthorization(); app.MapControllers(); +app.MapIdentityApi(); app.UseSwaggerSetup(); - app.Run(); \ No newline at end of file diff --git a/src/Equinox.UI.Web/Areas/Identity/IdentityHostingStartup.cs b/src/Equinox.UI.Web/Areas/Identity/IdentityHostingStartup.cs index d0cba12d..94a44287 100644 --- a/src/Equinox.UI.Web/Areas/Identity/IdentityHostingStartup.cs +++ b/src/Equinox.UI.Web/Areas/Identity/IdentityHostingStartup.cs @@ -1,6 +1,4 @@ -using Microsoft.AspNetCore.Hosting; - -[assembly: HostingStartup(typeof(Equinox.UI.Web.Areas.Identity.IdentityHostingStartup))] +[assembly: HostingStartup(typeof(Equinox.UI.Web.Areas.Identity.IdentityHostingStartup))] namespace Equinox.UI.Web.Areas.Identity { public class IdentityHostingStartup : IHostingStartup diff --git a/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Login.cshtml b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Login.cshtml new file mode 100644 index 00000000..6354164a --- /dev/null +++ b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Login.cshtml @@ -0,0 +1,83 @@ +@page +@model LoginModel + +@{ + ViewData["Title"] = "Log in"; +} + +

@ViewData["Title"]

+
+
+
+
+

Use a local account to log in.

+
+ +
+ + + +
+
+ + + +
+
+ +
+
+ +
+ +
+
+
+
+
+

Use another service to log in.

+
+ @{ + if ((Model.ExternalLogins?.Count ?? 0) == 0) + { +
+

+ There are no external authentication services configured. See this article + about setting up this ASP.NET application to support logging in via external services. +

+
+ } + else + { +
+
+

+ @foreach (var provider in Model.ExternalLogins!) + { + + } +

+
+
+ } + } +
+
+
+ +@section Scripts { + +} diff --git a/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Login.cshtml.cs new file mode 100644 index 00000000..37d2ead7 --- /dev/null +++ b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; + +namespace Equinox.UI.Web.Areas.Identity.Pages.Account +{ + [AllowAnonymous] + public class LoginModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public LoginModel(SignInManager signInManager, + ILogger logger, + UserManager userManager) + { + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + [BindProperty] + public InputModel Input { get; set; } + + public IList ExternalLogins { get; set; } + + public string ReturnUrl { get; set; } + + [TempData] + public string ErrorMessage { get; set; } + + public class InputModel + { + [Required] + [EmailAddress] + public string Email { get; set; } + + [Required] + [DataType(DataType.Password)] + public string Password { get; set; } + + [Display(Name = "Remember me?")] + public bool RememberMe { get; set; } + } + + public async Task OnGetAsync(string returnUrl = null) + { + if (!string.IsNullOrEmpty(ErrorMessage)) + { + ModelState.AddModelError(string.Empty, ErrorMessage); + } + + returnUrl ??= Url.Content("~/"); + + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); + + ReturnUrl = returnUrl; + } + + public async Task OnPostAsync(string returnUrl = null) + { + returnUrl ??= Url.Content("~/"); + + ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); + + if (ModelState.IsValid) + { + // This doesn't count login failures towards account lockout + // To enable password failures to trigger account lockout, set lockoutOnFailure: true + var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); + if (result.Succeeded) + { + _logger.LogInformation("User logged in."); + return LocalRedirect(returnUrl); + } + if (result.RequiresTwoFactor) + { + return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe }); + } + if (result.IsLockedOut) + { + _logger.LogWarning("User account locked out."); + return RedirectToPage("./Lockout"); + } + else + { + ModelState.AddModelError(string.Empty, "Invalid login attempt."); + return Page(); + } + } + + // If we got this far, something failed, redisplay form + return Page(); + } + } +} diff --git a/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Logout.cshtml b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Logout.cshtml new file mode 100644 index 00000000..cad49d71 --- /dev/null +++ b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Logout.cshtml @@ -0,0 +1,21 @@ +@page +@model LogoutModel +@{ + ViewData["Title"] = "Log out"; +} + +
+

@ViewData["Title"]

+ @{ + if (User.Identity?.IsAuthenticated ?? false) + { +
+ +
+ } + else + { +

You have successfully logged out of the application.

+ } + } +
diff --git a/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Logout.cshtml.cs b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Logout.cshtml.cs new file mode 100644 index 00000000..ffeec817 --- /dev/null +++ b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Logout.cshtml.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; + +namespace Equinox.UI.Web.Areas.Identity.Pages.Account +{ + [AllowAnonymous] + public class LogoutModel : PageModel + { + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public LogoutModel(SignInManager signInManager, ILogger logger) + { + _signInManager = signInManager; + _logger = logger; + } + + public void OnGet() + { + } + + public async Task OnPost(string returnUrl = null) + { + await _signInManager.SignOutAsync(); + _logger.LogInformation("User logged out."); + if (returnUrl != null) + { + return LocalRedirect(returnUrl); + } + else + { + return RedirectToPage(); + } + } + } +} diff --git a/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index 52fcec43..097fd1bd 100644 --- a/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -1,18 +1,12 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; +using System.ComponentModel.DataAnnotations; using System.Security.Claims; using System.Text; -using System.Text.Encodings.Web; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Logging; namespace Equinox.UI.Web.Areas.Identity.Pages.Account { @@ -22,18 +16,15 @@ public class RegisterModel : PageModel private readonly SignInManager _signInManager; private readonly UserManager _userManager; private readonly ILogger _logger; - private readonly IEmailSender _emailSender; public RegisterModel( UserManager userManager, SignInManager signInManager, - ILogger logger, - IEmailSender emailSender) + ILogger logger) { _userManager = userManager; _signInManager = signInManager; _logger = logger; - _emailSender = emailSender; } [BindProperty] @@ -91,9 +82,6 @@ public async Task OnPostAsync(string returnUrl = null) values: new { area = "Identity", userId = user.Id, code }, protocol: Request.Scheme); - await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", - $"Please confirm your account by clicking here."); - if (_userManager.Options.SignIn.RequireConfirmedAccount) { return RedirectToPage("RegisterConfirmation", new { email = Input.Email }); diff --git a/src/Equinox.UI.Web/Configurations/AutoMapperConfig.cs b/src/Equinox.UI.Web/Configurations/AutoMapperConfig.cs index a62cc018..7ef2217c 100644 --- a/src/Equinox.UI.Web/Configurations/AutoMapperConfig.cs +++ b/src/Equinox.UI.Web/Configurations/AutoMapperConfig.cs @@ -1,16 +1,16 @@ -using System; -using Equinox.Application.AutoMapper; -using Microsoft.Extensions.DependencyInjection; +using Equinox.Application.AutoMapper; namespace Equinox.UI.Web.Configurations { public static class AutoMapperConfig { - public static void AddAutoMapperConfiguration(this IServiceCollection services) + public static WebApplicationBuilder AddAutoMapperConfiguration(this WebApplicationBuilder builder) { - if (services == null) throw new ArgumentNullException(nameof(services)); + if (builder == null) throw new ArgumentNullException(nameof(builder)); - services.AddAutoMapper(typeof(DomainToViewModelMappingProfile), typeof(ViewModelToDomainMappingProfile)); + builder.Services.AddAutoMapper(typeof(DomainToViewModelMappingProfile), typeof(ViewModelToDomainMappingProfile)); + + return builder; } } } \ No newline at end of file diff --git a/src/Equinox.UI.Web/Configurations/DatabaseConfig.cs b/src/Equinox.UI.Web/Configurations/DatabaseConfig.cs index 0e189cdc..2b8cf404 100644 --- a/src/Equinox.UI.Web/Configurations/DatabaseConfig.cs +++ b/src/Equinox.UI.Web/Configurations/DatabaseConfig.cs @@ -1,22 +1,21 @@ -using System; -using Equinox.Infra.Data.Context; +using Equinox.Infra.Data.Context; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; namespace Equinox.UI.Web.Configurations { public static class DatabaseConfig { - public static void AddDatabaseConfiguration(this IServiceCollection services, IConfiguration configuration) + public static WebApplicationBuilder AddDatabaseConfiguration(this WebApplicationBuilder builder) { - if (services == null) throw new ArgumentNullException(nameof(services)); + if (builder == null) throw new ArgumentNullException(nameof(builder)); - services.AddDbContext(options => - options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); + builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); - services.AddDbContext(options => - options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); + builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); + + return builder; } } } \ No newline at end of file diff --git a/src/Equinox.UI.Web/Configurations/DependencyInjectionConfig.cs b/src/Equinox.UI.Web/Configurations/DependencyInjectionConfig.cs index bdd4dfe0..e518d50a 100644 --- a/src/Equinox.UI.Web/Configurations/DependencyInjectionConfig.cs +++ b/src/Equinox.UI.Web/Configurations/DependencyInjectionConfig.cs @@ -1,16 +1,14 @@ -using System; -using Equinox.Infra.CrossCutting.IoC; -using Microsoft.Extensions.DependencyInjection; +using Equinox.Infra.CrossCutting.IoC; namespace Equinox.UI.Web.Configurations { public static class DependencyInjectionConfig { - public static void AddDependencyInjectionConfiguration(this IServiceCollection services) + public static void AddDependencyInjectionConfiguration(this WebApplicationBuilder builder) { - if (services == null) throw new ArgumentNullException(nameof(services)); + if (builder == null) throw new ArgumentNullException(nameof(builder)); - NativeInjectorBootStrapper.RegisterServices(services); + NativeInjectorBootStrapper.RegisterServices(builder); } } } \ No newline at end of file diff --git a/src/Equinox.UI.Web/Configurations/IdentityConfig.cs b/src/Equinox.UI.Web/Configurations/IdentityConfig.cs index cc58b62f..9780c237 100644 --- a/src/Equinox.UI.Web/Configurations/IdentityConfig.cs +++ b/src/Equinox.UI.Web/Configurations/IdentityConfig.cs @@ -1,27 +1,8 @@ -using System; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace Equinox.UI.Web.Configurations +namespace Equinox.UI.Web.Configurations { public static class IdentityConfig { - public static void AddSocialAuthenticationConfiguration(this IServiceCollection services, IConfiguration configuration) - { - if (services == null) throw new ArgumentNullException(nameof(services)); - - services.AddAuthentication() - .AddFacebook(o => - { - o.AppId = configuration["Authentication:Facebook:AppId"]; - o.AppSecret = configuration["Authentication:Facebook:AppSecret"]; - }) - .AddGoogle(googleOptions => - { - googleOptions.ClientId = configuration["Authentication:Google:ClientId"]; - googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"]; - }); - } + } } \ No newline at end of file diff --git a/src/Equinox.UI.Web/Configurations/MediatRConfig.cs b/src/Equinox.UI.Web/Configurations/MediatRConfig.cs new file mode 100644 index 00000000..2b3dee57 --- /dev/null +++ b/src/Equinox.UI.Web/Configurations/MediatRConfig.cs @@ -0,0 +1,16 @@ +using System.Reflection; + +namespace Equinox.UI.Web.Configurations +{ + public static class MediatRConfig + { + public static WebApplicationBuilder AddMediatRConfiguration(this WebApplicationBuilder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); + + return builder; + } + } +} \ No newline at end of file diff --git a/src/Equinox.UI.Web/Configurations/MvcConfig.cs b/src/Equinox.UI.Web/Configurations/MvcConfig.cs new file mode 100644 index 00000000..0e7b903c --- /dev/null +++ b/src/Equinox.UI.Web/Configurations/MvcConfig.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Equinox.UI.Web.Configurations +{ + public static class MvcConfig + { + public static WebApplicationBuilder AddMvcConfiguration(this WebApplicationBuilder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + builder.Configuration + .SetBasePath(builder.Environment.ContentRootPath) + .AddJsonFile("appsettings.json", true, true) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true) + .AddEnvironmentVariables(); + + builder.Services.AddControllersWithViews(options => + { + options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); + }); + + builder.Services.AddRazorPages(); + + return builder; + } + } +} \ No newline at end of file diff --git a/src/Equinox.UI.Web/Controllers/BaseController.cs b/src/Equinox.UI.Web/Controllers/BaseController.cs index fa9f0d22..e0fa8be6 100644 --- a/src/Equinox.UI.Web/Controllers/BaseController.cs +++ b/src/Equinox.UI.Web/Controllers/BaseController.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Linq; -using FluentValidation.Results; +using FluentValidation.Results; using Microsoft.AspNetCore.Mvc; namespace Equinox.UI.Web.Controllers diff --git a/src/Equinox.UI.Web/Controllers/CustomerController.cs b/src/Equinox.UI.Web/Controllers/CustomerController.cs index a43ef1c3..3cfa5cdb 100644 --- a/src/Equinox.UI.Web/Controllers/CustomerController.cs +++ b/src/Equinox.UI.Web/Controllers/CustomerController.cs @@ -1,10 +1,8 @@ -using System; -using System.Threading.Tasks; using Equinox.Application.Interfaces; using Equinox.Application.ViewModels; +using Equinox.Infra.CrossCutting.Identity.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using NetDevPack.Identity.Authorization; namespace Equinox.UI.Web.Controllers { diff --git a/src/Equinox.UI.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs b/src/Equinox.UI.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs index 27e670ea..eaa9ec6c 100644 --- a/src/Equinox.UI.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs +++ b/src/Equinox.UI.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; -using System; namespace Equinox.UI.Web.Data.Migrations { diff --git a/src/Equinox.UI.Web/Equinox.UI.Web.csproj b/src/Equinox.UI.Web/Equinox.UI.Web.csproj index f944e223..cb732910 100644 --- a/src/Equinox.UI.Web/Equinox.UI.Web.csproj +++ b/src/Equinox.UI.Web/Equinox.UI.Web.csproj @@ -1,31 +1,30 @@ - net6.0 + net8.0 + disable aspnet-Equinox.UI.Web-61A38C1A-B3EE-4175-AD27-CD2A22786741 enable Linux ..\.. - - - - - - + + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + - - - + + + \ No newline at end of file diff --git a/src/Equinox.UI.Web/Program.cs b/src/Equinox.UI.Web/Program.cs index 1657d32c..07667e82 100644 --- a/src/Equinox.UI.Web/Program.cs +++ b/src/Equinox.UI.Web/Program.cs @@ -1,76 +1,38 @@ -using Equinox.Infra.CrossCutting.Identity; using Equinox.UI.Web.Configurations; -using MediatR; -using Microsoft.AspNetCore.Mvc; -using NetDevPack.Identity; -using NetDevPack.Identity.User; +using Equinox.Infra.CrossCutting.Identity.Configuration; var builder = WebApplication.CreateBuilder(args); -builder.Configuration - .SetBasePath(builder.Environment.ContentRootPath) - .AddJsonFile("appsettings.json", true, true) - .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true) - .AddEnvironmentVariables(); - -// ConfigureServices - -builder.Services.AddControllersWithViews(options => -{ - options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); -}); -builder.Services.AddRazorPages(); - -// Setting DBContexts -builder.Services.AddDatabaseConfiguration(builder.Configuration); - -// ASP.NET Identity Settings -builder.Services.AddWebAppIdentityConfiguration(builder.Configuration); - -// Authentication & Authorization -builder.Services.AddSocialAuthenticationConfiguration(builder.Configuration); - -// Interactive AspNetUser (logged in) -// NetDevPack.Identity dependency -builder.Services.AddAspNetUserConfiguration(); - -// AutoMapper Settings -builder.Services.AddAutoMapperConfiguration(); - -// Adding MediatR for Domain Events and Notifications -builder.Services.AddMediatR(AppDomain.CurrentDomain.GetAssemblies()); - -// .NET Native DI Abstraction -builder.Services.AddDependencyInjectionConfiguration(); +// Adding Services +builder.AddMvcConfiguration() // Entire Equinox MVC Config + .AddDatabaseConfiguration() // Setting DBContexts + .AddWebIdentityConfiguration() // ASP.NET Identity Config + .AddAutoMapperConfiguration() // AutoMapper Config + .AddMediatRConfiguration() // Adding MediatR for Domain Events and Notifications + .AddDependencyInjectionConfiguration(); // DotNet Native DI Abstraction var app = builder.Build(); -// Configure - +// Configure Services if (app.Environment.IsDevelopment()) { - app.UseDeveloperExceptionPage(); - app.UseMigrationsEndPoint(); + app.UseDeveloperExceptionPage() + .UseMigrationsEndPoint(); } else { - app.UseExceptionHandler("/error/500"); - app.UseStatusCodePagesWithRedirects("/error/{0}"); - app.UseHsts(); + app.UseExceptionHandler("/error/500") + .UseStatusCodePagesWithRedirects("/error/{0}") + .UseHsts(); } -app.UseHttpsRedirection(); -app.UseStaticFiles(); - -app.UseRouting(); - -// NetDevPack.Identity dependency -app.UseAuthConfiguration(); - -app.MapControllerRoute( - name: "default", - pattern: "{controller=Home}/{action=Index}/{id?}"); +app.UseHttpsRedirection() + .UseStaticFiles() + .UseRouting() + .UseAuthentication() + .UseAuthorization(); +app.MapControllerRoute(name: "default",pattern: "{controller=Home}/{action=Index}/{id?}"); app.MapRazorPages(); app.Run(); diff --git a/src/Equinox.UI.Web/Views/Customer/Index.cshtml b/src/Equinox.UI.Web/Views/Customer/Index.cshtml index 9309318f..235dc8e0 100644 --- a/src/Equinox.UI.Web/Views/Customer/Index.cshtml +++ b/src/Equinox.UI.Web/Views/Customer/Index.cshtml @@ -123,7 +123,7 @@ $(".viewbutton").on("click", function() { var customerId = $(this).data('id'); $.ajax({ - url: "https://localhost:44314/customer-management/customer-history/" + customerId, + url: "https://localhost:5001/customer-management/customer-history/" + customerId, //url: "http://equinoxproject.azurewebsites.net/customer-management/customer-history/" + customerId, cache: false }).done(function(data) { diff --git a/src/Equinox.UI.Web/Views/Home/Index.cshtml b/src/Equinox.UI.Web/Views/Home/Index.cshtml index c4e5a6c1..ee966bf9 100644 --- a/src/Equinox.UI.Web/Views/Home/Index.cshtml +++ b/src/Equinox.UI.Web/Views/Home/Index.cshtml @@ -20,11 +20,11 @@

Technologies

    -
  • .NET 6.0
  • -
  • ASP.NET MVC 6.0
  • -
  • ASP.NET WebAPI 6.0
  • -
  • ASP.NET Identity 6.0
  • -
  • EF Core 6.0
  • +
  • .NET 8.0
  • +
  • ASP.NET MVC 8.0
  • +
  • ASP.NET WebAPI 8.0
  • +
  • ASP.NET Identity 8.0
  • +
  • EF Core 8.0
  • AutoMapper
  • FluentValidator
  • MediatR
  • diff --git a/src/Equinox.UI.Web/wwwroot/images/banner1.svg b/src/Equinox.UI.Web/wwwroot/images/banner1.svg index ebd3bfe5..e17e7208 100644 --- a/src/Equinox.UI.Web/wwwroot/images/banner1.svg +++ b/src/Equinox.UI.Web/wwwroot/images/banner1.svg @@ -13,7 +13,7 @@ Run your application anywhere - ASP.NET 6.0 + ASP.NET 8.0