diff --git a/.editorconfig b/.editorconfig index cd17d141f..b84780595 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,7 +16,7 @@ indent_size = 2 # Code files [*.{cs,csx,vb,vbx}] -indent_size =2 +indent_size = 2 insert_final_newline = true charset = utf-8-bom ############################### @@ -61,18 +61,20 @@ dotnet_naming_style.pascal_case_style.capitalization = pascal_case # Use PascalCase for constant fields dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields -dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style dotnet_naming_symbols.constant_fields.applicable_kinds = field dotnet_naming_symbols.constant_fields.applicable_accessibilities = * dotnet_naming_symbols.constant_fields.required_modifiers = const -tab_width=2 +tab_width= 2 dotnet_naming_rule.private_members_with_underscore.symbols = private_fields -dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore +dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore dotnet_naming_rule.private_members_with_underscore.severity = suggestion dotnet_naming_symbols.private_fields.applicable_kinds = field dotnet_naming_symbols.private_fields.applicable_accessibilities = private dotnet_naming_style.prefix_underscore.capitalization = camel_case dotnet_naming_style.prefix_underscore.required_prefix = _ +dotnet_style_operator_placement_when_wrapping = beginning_of_line +end_of_line = crlf ############################### # C# Coding Conventions # ############################### @@ -134,6 +136,12 @@ csharp_space_between_method_call_empty_parameter_list_parentheses = false # Wrapping preferences csharp_preserve_single_line_statements = true csharp_preserve_single_line_blocks = true +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent ############################### # VB Coding Conventions # ############################### diff --git a/Directory.Packages.props b/Directory.Packages.props index 7f75a8875..a21e8efed 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,12 +18,15 @@ + + + @@ -34,6 +37,7 @@ + diff --git a/src/Clean.Architecture.Infrastructure/Clean.Architecture.Infrastructure.csproj b/src/Clean.Architecture.Infrastructure/Clean.Architecture.Infrastructure.csproj index 27e3650be..6dd6af0d0 100644 --- a/src/Clean.Architecture.Infrastructure/Clean.Architecture.Infrastructure.csproj +++ b/src/Clean.Architecture.Infrastructure/Clean.Architecture.Infrastructure.csproj @@ -10,9 +10,12 @@ + + + diff --git a/src/Clean.Architecture.Infrastructure/Data/AppDbContext.cs b/src/Clean.Architecture.Infrastructure/Data/AppDbContext.cs index 6de13cae8..37fa3ccfe 100644 --- a/src/Clean.Architecture.Infrastructure/Data/AppDbContext.cs +++ b/src/Clean.Architecture.Infrastructure/Data/AppDbContext.cs @@ -20,7 +20,7 @@ public AppDbContext(DbContextOptions options, public DbSet ToDoItems => Set(); public DbSet Projects => Set(); - public DbSet Contributors => Set(); + public DbSet Contributors => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/Clean.Architecture.Infrastructure/DefaultInfrastructureModule.cs b/src/Clean.Architecture.Infrastructure/DefaultInfrastructureModule.cs index bc5756c44..5ca135af2 100644 --- a/src/Clean.Architecture.Infrastructure/DefaultInfrastructureModule.cs +++ b/src/Clean.Architecture.Infrastructure/DefaultInfrastructureModule.cs @@ -3,6 +3,7 @@ using Clean.Architecture.Core.Interfaces; using Clean.Architecture.Core.ProjectAggregate; using Clean.Architecture.Infrastructure.Data; +using Clean.Architecture.Infrastructure.Identity.Jwt; using Clean.Architecture.SharedKernel; using Clean.Architecture.SharedKernel.Interfaces; using MediatR; @@ -54,6 +55,9 @@ protected override void Load(ContainerBuilder builder) private void RegisterCommonDependencies(ContainerBuilder builder) { + builder.RegisterType() + .As().InstancePerLifetimeScope(); + builder.RegisterGeneric(typeof(EfRepository<>)) .As(typeof(IRepository<>)) .As(typeof(IReadRepository<>)) @@ -69,10 +73,10 @@ private void RegisterCommonDependencies(ContainerBuilder builder) .As() .InstancePerLifetimeScope(); + //builder.Register(context => //{ // var c = context.Resolve(); - // return t => c.Resolve(t); //}); diff --git a/src/Clean.Architecture.Infrastructure/Identity/AppIdentityDbContext.cs b/src/Clean.Architecture.Infrastructure/Identity/AppIdentityDbContext.cs new file mode 100644 index 000000000..d2f3f26a5 --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Identity/AppIdentityDbContext.cs @@ -0,0 +1,22 @@ +using System.Reflection.Emit; +using System.Reflection; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Clean.Architecture.Infrastructure.Identity; +public class AppIdentityDbContext : IdentityDbContext +{ + public AppIdentityDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + // Customize the ASP.NET Identity model and override the defaults if needed. + // For example, you can rename the ASP.NET Identity table names and more. + // Add your customizations after calling base.OnModelCreating(builder); + } +} diff --git a/src/Clean.Architecture.Infrastructure/Identity/Jwt/AccessToken.cs b/src/Clean.Architecture.Infrastructure/Identity/Jwt/AccessToken.cs new file mode 100644 index 000000000..9ddbec867 --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Identity/Jwt/AccessToken.cs @@ -0,0 +1,16 @@ +using System.IdentityModel.Tokens.Jwt; + +namespace Clean.Architecture.Infrastructure.Identity.Jwt; +public class AccessToken +{ + public string Access_Token { get; set; } + public string TokenType { get; set; } + public int ExpiresIn { get; set; } + + public AccessToken(JwtSecurityToken securityToken) + { + Access_Token = new JwtSecurityTokenHandler().WriteToken(securityToken); + TokenType = "Bearer"; + ExpiresIn = (int)(securityToken.ValidTo - DateTime.UtcNow).TotalSeconds; + } +} diff --git a/src/Clean.Architecture.Infrastructure/Identity/Jwt/IJwtService.cs b/src/Clean.Architecture.Infrastructure/Identity/Jwt/IJwtService.cs new file mode 100644 index 000000000..590d12d1c --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Identity/Jwt/IJwtService.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Clean.Architecture.Infrastructure.Identity.Jwt; +public interface IJwtService +{ + Task GenerateAsync(User user); + int? ValidateJwtAccessTokenAsync(string token); +} diff --git a/src/Clean.Architecture.Infrastructure/Identity/Jwt/JwtService.cs b/src/Clean.Architecture.Infrastructure/Identity/Jwt/JwtService.cs new file mode 100644 index 000000000..36cf7aa27 --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Identity/Jwt/JwtService.cs @@ -0,0 +1,98 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Clean.Architecture.Infrastructure.Identity.Jwt; +public class JwtService : IJwtService +{ + private readonly SiteSettings _siteSetting; + private readonly UserManager _userManager; + + public JwtService(IOptionsSnapshot settings, + UserManager userManager) + { + _siteSetting = settings.Value; + _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); + } + + public async Task GenerateAsync(User user) + { + var secretKey = Encoding.UTF8.GetBytes(_siteSetting.JwtSettings.SecretKey); // longer that 16 character + var signingCredentials = new SigningCredentials(new SymmetricSecurityKey(secretKey), SecurityAlgorithms.HmacSha256Signature); + + //We can use EncryptingCredentials options in SecurityTokenDescriptor and hence our JWT token will not be parsed by jwt.io site and it will only be decrypted only by our code. + //Hence you can secure your token and who can see it + //var encryptionKey = Encoding.UTF8.GetBytes(_siteSetting.JwtSettings.EncryptKey); //must be 16 character + //var encryptingCredentials = new EncryptingCredentials(new SymmetricSecurityKey(encryptionKey), SecurityAlgorithms.Aes128KW, SecurityAlgorithms.Aes128CbcHmacSha256); + + var claims = await GetClaimsAsync(user); + + var descriptor = new SecurityTokenDescriptor + { + Issuer = _siteSetting.JwtSettings.Issuer, + Audience = _siteSetting.JwtSettings.Audience, + IssuedAt = DateTime.Now, + NotBefore = DateTime.Now.AddMinutes(_siteSetting.JwtSettings.NotBeforeMinutes), + Expires = DateTime.Now.AddMinutes(_siteSetting.JwtSettings.ExpirationMinutes), + SigningCredentials = signingCredentials, + //EncryptingCredentials = encryptingCredentials, + Subject = new ClaimsIdentity(claims) + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + + var securityToken = tokenHandler.CreateJwtSecurityToken(descriptor); + + return new AccessToken(securityToken: securityToken); + } + + public int? ValidateJwtAccessTokenAsync(string token) + { + var secretKey = Encoding.UTF8.GetBytes(_siteSetting.JwtSettings.SecretKey); // longer that 16 character + + //if you are giving a value to EncryptingCredentials while generating a token then uncomment the encryptionKey and TokenDecryptionKey option so token can be parsed while validating. + //var encryptionKey = Encoding.UTF8.GetBytes(_siteSetting.JwtSettings.EncryptKey); //must be 16 character + + var tokenHandler = new JwtSecurityTokenHandler(); + try + { + tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(secretKey), + //TokenDecryptionKey = new SymmetricSecurityKey(encryptionKey), + ValidateIssuer = false, + ValidateAudience = false, + // set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later) + ClockSkew = TimeSpan.Zero + }, out var validatedToken); + + var jwtSecurityToken = (JwtSecurityToken)validatedToken; + var userId = int.Parse(jwtSecurityToken.Claims.First(claim => claim.Type == "nameid").Value); + return userId; + } + catch + { + throw; + } + } + + private async Task> GetClaimsAsync(User user) + { + var claims = new List(); + claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); + claims.Add(new Claim(ClaimTypes.Name, user.UserName!)); + + var userRoles = await _userManager.GetRolesAsync(user); + + foreach (var role in userRoles) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + + return claims; + } +} diff --git a/src/Clean.Architecture.Infrastructure/Identity/Role.cs b/src/Clean.Architecture.Infrastructure/Identity/Role.cs new file mode 100644 index 000000000..42bc43637 --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Identity/Role.cs @@ -0,0 +1,8 @@ +using Clean.Architecture.SharedKernel.Interfaces; +using Microsoft.AspNetCore.Identity; + +namespace Clean.Architecture.Infrastructure.Identity; +public class Role : IdentityRole, IAggregateRoot +{ + public string Description { get; set; } = default!; +} diff --git a/src/Clean.Architecture.Infrastructure/Identity/SiteSettings.cs b/src/Clean.Architecture.Infrastructure/Identity/SiteSettings.cs new file mode 100644 index 000000000..4400fb1a8 --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Identity/SiteSettings.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Clean.Architecture.Infrastructure.Identity; +public class SiteSettings +{ + public JwtSettings JwtSettings { get; set; } = default!; + public IdentitySettings IdentitySettings { get; set; } = default!; +} + +public class IdentitySettings +{ + public bool PasswordRequireDigit { get; set; } + public int PasswordRequiredLength { get; set; } + public bool PasswordRequireNonAlphanumeric { get; set; } + public bool PasswordRequireUppercase { get; set; } + public bool PasswordRequireLowercase { get; set; } + public bool RequireUniqueEmail { get; set; } +} + +public class JwtSettings +{ + public string SecretKey { get; set; } = default!; + public string EncryptKey { get; set; } = default!; + public string Issuer { get; set; } = default!; + public string Audience { get; set; } = default!; + public int NotBeforeMinutes { get; set; } + public int ExpirationMinutes { get; set; } + public int RefreshTokenValidityInDays { get; set; } +} diff --git a/src/Clean.Architecture.Infrastructure/Identity/User.cs b/src/Clean.Architecture.Infrastructure/Identity/User.cs new file mode 100644 index 000000000..2af38ce40 --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Identity/User.cs @@ -0,0 +1,27 @@ +using Clean.Architecture.SharedKernel.Interfaces; +using Microsoft.AspNetCore.Identity; + +namespace Clean.Architecture.Infrastructure.Identity; +public class User : IdentityUser, IAggregateRoot +{ + public User() + { + IsActive = true; + } + + public string FullName { get; set; } = default!; + + public int Age { get; set; } + + public GenderType Gender { get; set; } + + public bool IsActive { get; set; } + + public DateTimeOffset? LastLoginDate { get; set; } +} + +public enum GenderType +{ + Male = 1, + Female = 2 +} diff --git a/src/Clean.Architecture.Infrastructure/Migrations/20230416152439_IdentityInitialization.Designer.cs b/src/Clean.Architecture.Infrastructure/Migrations/20230416152439_IdentityInitialization.Designer.cs new file mode 100644 index 000000000..2ca764810 --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Migrations/20230416152439_IdentityInitialization.Designer.cs @@ -0,0 +1,385 @@ +// +using System; +using Clean.Architecture.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Clean.Architecture.Infrastructure.Migrations +{ + [DbContext(typeof(AppIdentityDbContext))] + [Migration("20230416152439_IdentityInitialization")] + partial class IdentityInitialization + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Clean.Architecture.Core.ContributorAggregate.Contributor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Contributor"); + }); + + modelBuilder.Entity("Clean.Architecture.Core.ProjectAggregate.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Project"); + }); + + modelBuilder.Entity("Clean.Architecture.Core.ProjectAggregate.ToDoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContributorId") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDone") + .HasColumnType("bit"); + + b.Property("ProjectId") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("ToDoItem"); + }); + + modelBuilder.Entity("Clean.Architecture.Infrastructure.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .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("Clean.Architecture.Infrastructure.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Age") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastLoginDate") + .HasColumnType("datetimeoffset"); + + 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.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") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (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") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Clean.Architecture.Core.ProjectAggregate.ToDoItem", b => + { + b.HasOne("Clean.Architecture.Core.ProjectAggregate.Project", null) + .WithMany("Items") + .HasForeignKey("ProjectId"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Clean.Architecture.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Clean.Architecture.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Clean.Architecture.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Clean.Architecture.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Clean.Architecture.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Clean.Architecture.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Clean.Architecture.Core.ProjectAggregate.Project", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Clean.Architecture.Infrastructure/Migrations/20230416152439_IdentityInitialization.cs b/src/Clean.Architecture.Infrastructure/Migrations/20230416152439_IdentityInitialization.cs new file mode 100644 index 000000000..cc1c390ae --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Migrations/20230416152439_IdentityInitialization.cs @@ -0,0 +1,295 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Clean.Architecture.Infrastructure.Migrations +{ + /// + public partial class IdentityInitialization : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Description = table.Column(type: "nvarchar(max)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + FullName = table.Column(type: "nvarchar(max)", nullable: false), + Age = table.Column(type: "int", nullable: false), + Gender = table.Column(type: "int", nullable: false), + IsActive = table.Column(type: "bit", nullable: false), + LastLoginDate = table.Column(type: "datetimeoffset", nullable: true), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Contributor", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Contributor", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Project", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Priority = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Project", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "int", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "int", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "int", nullable: false), + RoleId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "int", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ToDoItem", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Title = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: false), + ContributorId = table.Column(type: "int", nullable: true), + IsDone = table.Column(type: "bit", nullable: false), + ProjectId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ToDoItem", x => x.Id); + table.ForeignKey( + name: "FK_ToDoItem_Project_ProjectId", + column: x => x.ProjectId, + principalTable: "Project", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_ToDoItem_ProjectId", + table: "ToDoItem", + column: "ProjectId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "Contributor"); + + migrationBuilder.DropTable( + name: "ToDoItem"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + + migrationBuilder.DropTable( + name: "Project"); + } + } +} diff --git a/src/Clean.Architecture.Infrastructure/Migrations/AppIdentityDbContextModelSnapshot.cs b/src/Clean.Architecture.Infrastructure/Migrations/AppIdentityDbContextModelSnapshot.cs new file mode 100644 index 000000000..1fbb1d7a4 --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Migrations/AppIdentityDbContextModelSnapshot.cs @@ -0,0 +1,382 @@ +// +using System; +using Clean.Architecture.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Clean.Architecture.Infrastructure.Migrations +{ + [DbContext(typeof(AppIdentityDbContext))] + partial class AppIdentityDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Clean.Architecture.Core.ContributorAggregate.Contributor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Contributor"); + }); + + modelBuilder.Entity("Clean.Architecture.Core.ProjectAggregate.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Project"); + }); + + modelBuilder.Entity("Clean.Architecture.Core.ProjectAggregate.ToDoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContributorId") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDone") + .HasColumnType("bit"); + + b.Property("ProjectId") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("ToDoItem"); + }); + + modelBuilder.Entity("Clean.Architecture.Infrastructure.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .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("Clean.Architecture.Infrastructure.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Age") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastLoginDate") + .HasColumnType("datetimeoffset"); + + 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.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") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (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") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Clean.Architecture.Core.ProjectAggregate.ToDoItem", b => + { + b.HasOne("Clean.Architecture.Core.ProjectAggregate.Project", null) + .WithMany("Items") + .HasForeignKey("ProjectId"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Clean.Architecture.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Clean.Architecture.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Clean.Architecture.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Clean.Architecture.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Clean.Architecture.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Clean.Architecture.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Clean.Architecture.Core.ProjectAggregate.Project", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Clean.Architecture.Infrastructure/StartupSetup.cs b/src/Clean.Architecture.Infrastructure/StartupSetup.cs index f17deeeaf..5acc09928 100644 --- a/src/Clean.Architecture.Infrastructure/StartupSetup.cs +++ b/src/Clean.Architecture.Infrastructure/StartupSetup.cs @@ -1,6 +1,15 @@ -using Clean.Architecture.Infrastructure.Data; +using System.Security.Claims; +using System.Text; +using Clean.Architecture.Infrastructure.Data; +using Clean.Architecture.SharedKernel.Utilities; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using System.Text.Json; +using Clean.Architecture.Infrastructure.Identity; namespace Clean.Architecture.Infrastructure; @@ -9,4 +18,136 @@ public static class StartupSetup public static void AddDbContext(this IServiceCollection services, string connectionString) => services.AddDbContext(options => options.UseSqlite(connectionString)); // will be created in web project root + + public static void AddCustomJwtAuthentication(this IServiceCollection services, JwtSettings settings) + { + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + var secretKey = Encoding.UTF8.GetBytes(settings.SecretKey); + //You can uncomment encryptionKey and TokenDecryptionKey fields if you are going to use it + //var encryptionKey = Encoding.UTF8.GetBytes(settings.EncryptKey); + + var validationParameters = new TokenValidationParameters + { + ClockSkew = TimeSpan.Zero, // default: 5 min + RequireSignedTokens = true, + + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(secretKey), + + RequireExpirationTime = true, + ValidateLifetime = true, + + ValidateAudience = true, //default : false + ValidAudience = settings.Audience, + + ValidateIssuer = true, //default : false + ValidIssuer = settings.Issuer, + + //TokenDecryptionKey = new SymmetricSecurityKey(encryptionKey) + }; + + options.RequireHttpsMetadata = false; + options.SaveToken = true; + options.TokenValidationParameters = validationParameters; + + options.Events = new JwtJwtBearerHelperEvents().GetJwtBeareEvents(); + }); + } + + public static void AddCustomIdentity(this IServiceCollection services, IdentitySettings settings, string connectionString) + { + services.AddDbContext(options => + options.UseSqlServer(connectionString)); + services.AddIdentity(identityOptions => + { + //Password Settings + identityOptions.Password.RequireDigit = settings.PasswordRequireDigit; + identityOptions.Password.RequiredLength = settings.PasswordRequiredLength; + identityOptions.Password.RequireNonAlphanumeric = settings.PasswordRequireNonAlphanumeric; //#@! + identityOptions.Password.RequireUppercase = settings.PasswordRequireUppercase; + identityOptions.Password.RequireLowercase = settings.PasswordRequireLowercase; + + //UserName Settings + identityOptions.User.RequireUniqueEmail = settings.RequireUniqueEmail; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + } } +/// +/// A helper JwtJwtBearerHelperEvents class which is added to make the code more readable and avoid any complexity +/// +internal class JwtJwtBearerHelperEvents +{ + public JwtBearerEvents GetJwtBeareEvents() + { + return new JwtBearerEvents + { + OnAuthenticationFailed = OnAuthenticationFailedJwtBearerEvent(), + OnTokenValidated = OnTokenValidatedJwtBearerEvent(), + OnChallenge = OnOnChallengeJwtBearerEvent() + }; + } + private Func OnAuthenticationFailedJwtBearerEvent() + { + return (context) => + { + if (context.Exception != null) + { + throw context.Exception; + } + return Task.CompletedTask; + }; + } + private Func OnTokenValidatedJwtBearerEvent() + { + return async context => + { + var signInManager = context.HttpContext.RequestServices.GetRequiredService>(); + + var claimsIdentity = context.Principal!.Identity as ClaimsIdentity; + + if (claimsIdentity==null) + context.Fail("This token has no claims identity."); + + if (claimsIdentity!.Claims?.Any() != true) + context.Fail("This token has no claims."); + + //Get UserId and check if user exists in db + var userId = claimsIdentity.GetUserId(); + if (userId == 0) + context.Fail("UserId has no value in claims."); + var user = await signInManager.FindByIdAsync(userId.ToString()); + if (user == null) + context.Fail("User not found."); + + if (!user!.IsActive) + context.Fail("User is not active."); + + user.LastLoginDate = DateTime.Now; + await signInManager.UpdateAsync(user); + }; + } + private Func OnOnChallengeJwtBearerEvent() + { + return context => + { + if (context.AuthenticateFailure != null) + throw context.AuthenticateFailure; + context.HandleResponse(); + context.Response.StatusCode = 401; + context.Response.ContentType = "application/json"; + var result = JsonSerializer.Serialize(new { message = "You are not Authorized" }); + return context.Response.WriteAsync(result); + }; + } +} + + diff --git a/src/Clean.Architecture.SharedKernel/Clean.Architecture.SharedKernel.csproj b/src/Clean.Architecture.SharedKernel/Clean.Architecture.SharedKernel.csproj index 36765a764..04735fcac 100644 --- a/src/Clean.Architecture.SharedKernel/Clean.Architecture.SharedKernel.csproj +++ b/src/Clean.Architecture.SharedKernel/Clean.Architecture.SharedKernel.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Clean.Architecture.SharedKernel/Utilities/IdentityExtension.cs b/src/Clean.Architecture.SharedKernel/Utilities/IdentityExtension.cs new file mode 100644 index 000000000..81f1bcb8c --- /dev/null +++ b/src/Clean.Architecture.SharedKernel/Utilities/IdentityExtension.cs @@ -0,0 +1,36 @@ +using System.Globalization; +using System.Security.Claims; +using System.Security.Principal; + +namespace Clean.Architecture.SharedKernel.Utilities; +public static class IdentityExtensions +{ + public static string? FindFirstValue(this ClaimsIdentity identity, string claimType) + { + return identity?.FindFirst(claimType)?.Value; + } + + public static string? FindFirstValue(this IIdentity identity, string claimType) + { + var claimsIdentity = identity as ClaimsIdentity; + return claimsIdentity?.FindFirstValue(claimType); + } + + public static string? GetUserId(this IIdentity identity) + { + return identity?.FindFirstValue(ClaimTypes.NameIdentifier); + } + + public static T? GetUserId(this IIdentity identity) where T : IConvertible + { + var userId = identity?.GetUserId(); + return !string.IsNullOrWhiteSpace(userId) + ? (T)Convert.ChangeType(userId, typeof(T), CultureInfo.InvariantCulture) + : default; + } + + public static string? GetUserName(this IIdentity identity) + { + return identity?.FindFirstValue(ClaimTypes.Name); + } +} diff --git a/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/Create.cs b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/Create.cs index c10cb36dd..28dcf6c17 100644 --- a/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/Create.cs +++ b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/Create.cs @@ -1,6 +1,7 @@ using Ardalis.ApiEndpoints; using Clean.Architecture.Core.ProjectAggregate; using Clean.Architecture.SharedKernel.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Swashbuckle.AspNetCore.Annotations; @@ -24,6 +25,7 @@ public Create(IRepository repository) OperationId = "Project.Create", Tags = new[] { "ProjectEndpoints" }) ] + [Authorize] public override async Task> HandleAsync( CreateProjectRequest request, CancellationToken cancellationToken = new()) diff --git a/src/Clean.Architecture.Web/Endpoints/UserEndpoints/Login.LoginUserRequest.cs b/src/Clean.Architecture.Web/Endpoints/UserEndpoints/Login.LoginUserRequest.cs new file mode 100644 index 000000000..49e9f12b6 --- /dev/null +++ b/src/Clean.Architecture.Web/Endpoints/UserEndpoints/Login.LoginUserRequest.cs @@ -0,0 +1,13 @@ +using Microsoft.Build.Framework; + +namespace Clean.Architecture.Web.Endpoints.UserEndpoints; + +public class LoginUserRequest +{ + public const string Route = "/User/Login"; + [Required] + public string Email { get; set; } = default!; + + [Required] + public string Password { get; set; } = default!; +} diff --git a/src/Clean.Architecture.Web/Endpoints/UserEndpoints/Login.LoginUserResponse.cs b/src/Clean.Architecture.Web/Endpoints/UserEndpoints/Login.LoginUserResponse.cs new file mode 100644 index 000000000..dae5570d6 --- /dev/null +++ b/src/Clean.Architecture.Web/Endpoints/UserEndpoints/Login.LoginUserResponse.cs @@ -0,0 +1,8 @@ +namespace Clean.Architecture.Web.Endpoints.UserEndpoints; + +public class LoginUserResponse +{ + public string AccessToken { get; set; } = default!; + public string TokenType { get; set; } = default!; + public int ExpiresIn { get; set; } +} diff --git a/src/Clean.Architecture.Web/Endpoints/UserEndpoints/Login.cs b/src/Clean.Architecture.Web/Endpoints/UserEndpoints/Login.cs new file mode 100644 index 000000000..650de5af7 --- /dev/null +++ b/src/Clean.Architecture.Web/Endpoints/UserEndpoints/Login.cs @@ -0,0 +1,49 @@ +using Clean.Architecture.Infrastructure.Identity; +using Clean.Architecture.Infrastructure.Identity.Jwt; +using FastEndpoints; +using Microsoft.AspNetCore.Identity; + +namespace Clean.Architecture.Web.Endpoints.UserEndpoints; + +public class Login : Endpoint +{ + private readonly UserManager _userManager; + private readonly IJwtService _jwtService; + public Login(UserManager userManager, IJwtService jwtService) + { + _userManager = userManager; + _jwtService = jwtService; + } + + public override void Configure() + { + Post(LoginUserRequest.Route); + AllowAnonymous(); + Options(x => x + .WithTags("UserEndpoints")); + } + public override async Task HandleAsync( + LoginUserRequest request, + CancellationToken cancellationToken) + { + if (request is null) + ThrowError("Request body is empty"); + var user = await _userManager.FindByEmailAsync(request.Email); //userName/email can be used to find a unique user + if (user == null) + ThrowError("username or password is incorrect"); + + var isPasswordValid = await _userManager.CheckPasswordAsync(user, request.Password); + if (!isPasswordValid) + ThrowError("username or password is incorrect"); + + var jwt = await _jwtService.GenerateAsync(user); + user.LastLoginDate = DateTime.UtcNow; + await _userManager.UpdateAsync(user); + await SendOkAsync(new LoginUserResponse + { + AccessToken = jwt.Access_Token, + TokenType = jwt.TokenType, + ExpiresIn = jwt.ExpiresIn + }); + } +} diff --git a/src/Clean.Architecture.Web/Endpoints/UserEndpoints/SignUp.SignUpUserRequest.cs b/src/Clean.Architecture.Web/Endpoints/UserEndpoints/SignUp.SignUpUserRequest.cs new file mode 100644 index 000000000..2611571ed --- /dev/null +++ b/src/Clean.Architecture.Web/Endpoints/UserEndpoints/SignUp.SignUpUserRequest.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using Clean.Architecture.Infrastructure.Identity; + +namespace Clean.Architecture.Web.Endpoints.UserEndpoints; + +public class SignUpUserRequest +{ + public const string Route = "/User/SignUp"; + + [Required] + public string UserName { get; set; } = default!; + + [Required] + public string Email { get; set; } = default!; + + + [Required] + public string Password { get; set; } = default!; + + [Required] + public string FullName { get; set; } = default!; + + [Required] + public int Age { get; set; } + + [Required] + public GenderType Gender { get; set; } +} diff --git a/src/Clean.Architecture.Web/Endpoints/UserEndpoints/SignUp.cs b/src/Clean.Architecture.Web/Endpoints/UserEndpoints/SignUp.cs new file mode 100644 index 000000000..54bc42522 --- /dev/null +++ b/src/Clean.Architecture.Web/Endpoints/UserEndpoints/SignUp.cs @@ -0,0 +1,69 @@ +using Clean.Architecture.Infrastructure.Identity; +using FastEndpoints; +using Microsoft.AspNetCore.Identity; + +namespace Clean.Architecture.Web.Endpoints.UserEndpoints; + +public class SignUp : Endpoint +{ + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + public SignUp(UserManager userManager, RoleManager roleManager) + { + _userManager = userManager; + _roleManager = roleManager; + } + + public override void Configure() + { + Post(SignUpUserRequest.Route); + AllowAnonymous(); + Options(x => x + .WithTags("UserEndpoints")); + } + public override async Task HandleAsync( + SignUpUserRequest request, + CancellationToken cancellationToken) + { + if (request is null) + ThrowError("Request body is empty"); + //TODO:CHECK FOR USERNAME EMPTY OR NULL + var newUser = new User + { + Age = request.Age, + FullName = request.FullName!, + Gender = request.Gender, + UserName = request.UserName, + Email = request.Email + }; + var createUserResult = await _userManager.CreateAsync(newUser, request.Password); + if (!createUserResult.Succeeded) { ThrowValidationErrors(createUserResult.Errors); } + + var role = await _roleManager.FindByNameAsync("Admin"); + if (role==null) + { + var addRoleResult = await _roleManager.CreateAsync(new Role + { + Name = "Admin", + Description = "admin role" + }); + if (!addRoleResult.Succeeded) { ThrowValidationErrors(addRoleResult.Errors); } + } + else + { + var assignRoleResult = await _userManager.AddToRoleAsync(newUser, role.Name!); + if (!assignRoleResult.Succeeded) { ThrowValidationErrors(assignRoleResult.Errors); } + } + + await SendNoContentAsync(); + } + private void ThrowValidationErrors(IEnumerable identityErrors) + { + foreach (var error in identityErrors) + { + AddError(error.Description, error.Code); + } + ThrowIfAnyErrors(); + } +} + diff --git a/src/Clean.Architecture.Web/Program.cs b/src/Clean.Architecture.Web/Program.cs index 5f63398c1..5dec8d0fc 100644 --- a/src/Clean.Architecture.Web/Program.cs +++ b/src/Clean.Architecture.Web/Program.cs @@ -10,6 +10,7 @@ using FastEndpoints.ApiExplorer; using Microsoft.OpenApi.Models; using Serilog; +using Clean.Architecture.Infrastructure.Identity; var builder = WebApplication.CreateBuilder(args); @@ -37,6 +38,11 @@ c.EnableAnnotations(); c.OperationFilter(); }); +builder.Services.Configure(builder.Configuration.GetSection(nameof(SiteSettings))); + +var sitSettings = builder.Configuration.GetSection(nameof(SiteSettings)).Get(); +builder.Services.AddCustomIdentity(sitSettings!.IdentitySettings, connectionString!); +builder.Services.AddCustomJwtAuthentication(sitSettings!.JwtSettings); // add list services for diagnostic purposes - see https://github.com/ardalis/AspNetCoreStartupServices builder.Services.Configure(config => @@ -70,6 +76,8 @@ } app.UseRouting(); app.UseFastEndpoints(); +app.UseAuthentication(); +app.UseAuthorization(); app.UseHttpsRedirection(); app.UseStaticFiles(); diff --git a/src/Clean.Architecture.Web/appsettings.json b/src/Clean.Architecture.Web/appsettings.json index 7331e3538..6504e9416 100644 --- a/src/Clean.Architecture.Web/appsettings.json +++ b/src/Clean.Architecture.Web/appsettings.json @@ -28,5 +28,24 @@ // } //} ] + }, + "SiteSettings": { + "JwtSettings": { + "SecretKey": "LongerThan-16Char-SecretKey", + "EncryptKey": "16CharEncryptKey", + "Issuer": "CleanArchTemplate", + "Audience": "CleanArchTemplate", + "NotBeforeMinutes": "0", + "ExpirationMinutes": "1440", + "RefreshTokenValidityInDays": 7 + }, + "IdentitySettings": { + "PasswordRequireDigit": "true", + "PasswordRequiredLength": "6", + "PasswordRequireNonAlphanumeric": "false", + "PasswordRequireUppercase": "false", + "PasswordRequireLowercase": "false", + "RequireUniqueEmail": "true" + } } }