From 8f7df85d496be3f6166c8286f9b8ebae64152c07 Mon Sep 17 00:00:00 2001 From: Andrew Song Date: Mon, 21 Dec 2020 09:24:21 -0600 Subject: [PATCH] Refractor token auth stuff to use identiycore framework --- API/API.csproj | 1 + API/Controllers/AccountController.cs | 61 ++--- API/Controllers/LibraryController.cs | 3 - API/Data/DataContext.cs | 25 +- .../Migrations/DataContextModelSnapshot.cs | 241 +++++++++++++++++- API/Data/Seed.cs | 24 ++ API/Entities/AppRole.cs | 10 + API/Entities/AppUser.cs | 11 +- API/Entities/AppUserRole.cs | 10 + API/Extensions/IdentityServiceExtensions.cs | 17 +- API/Helpers/AutoMapperProfiles.cs | 2 + API/Interfaces/ITokenService.cs | 5 +- API/Program.cs | 4 + API/Services/TokenService.cs | 13 +- 14 files changed, 377 insertions(+), 50 deletions(-) create mode 100644 API/Data/Seed.cs create mode 100644 API/Entities/AppRole.cs create mode 100644 API/Entities/AppUserRole.cs diff --git a/API/API.csproj b/API/API.csproj index 784cff6b4..52664396f 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -8,6 +8,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index a621c1086..a007bfc6c 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -1,11 +1,10 @@ using System; -using System.Security.Cryptography; -using System.Text; using System.Threading.Tasks; -using API.Data; using API.DTOs; using API.Entities; using API.Interfaces; +using AutoMapper; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -14,17 +13,25 @@ namespace API.Controllers { public class AccountController : BaseApiController { - private readonly DataContext _context; + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; private readonly ITokenService _tokenService; private readonly IUserRepository _userRepository; private readonly ILogger _logger; + private readonly IMapper _mapper; - public AccountController(DataContext context, ITokenService tokenService, IUserRepository userRepository, ILogger logger) + public AccountController(UserManager userManager, + SignInManager signInManager, + ITokenService tokenService, IUserRepository userRepository, + ILogger logger, + IMapper mapper) { - _context = context; + _userManager = userManager; + _signInManager = signInManager; _tokenService = tokenService; _userRepository = userRepository; _logger = logger; + _mapper = mapper; } [HttpPost("register")] @@ -35,24 +42,21 @@ namespace API.Controllers { return BadRequest("Username is taken."); } + + var user = _mapper.Map(registerDto); + + var result = await _userManager.CreateAsync(user, registerDto.Password); + + if (!result.Succeeded) return BadRequest(result.Errors); + + var roleResult = await _userManager.AddToRoleAsync(user, "Pleb"); + + if (!roleResult.Succeeded) return BadRequest(result.Errors); - using var hmac = new HMACSHA512(); - var user = new AppUser - { - UserName = registerDto.Username.ToLower(), - PasswordHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(registerDto.Password)), - PasswordSalt = hmac.Key, - IsAdmin = registerDto.IsAdmin, - LastActive = DateTime.Now - }; - - _context.Users.Add(user); - await _context.SaveChangesAsync(); - return new UserDto() { Username = user.UserName, - Token = _tokenService.CreateToken(user), + Token = await _tokenService.CreateToken(user), IsAdmin = user.IsAdmin }; } @@ -60,18 +64,15 @@ namespace API.Controllers [HttpPost("login")] public async Task> Login(LoginDto loginDto) { - var user = await _userRepository.GetUserByUsernameAsync(loginDto.Username); + var user = await _userManager.Users + .SingleOrDefaultAsync(x => x.UserName == loginDto.Username.ToLower()); if (user == null) return Unauthorized("Invalid username"); - - using var hmac = new HMACSHA512(user.PasswordSalt); - var computedHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(loginDto.Password)); + var result = await _signInManager + .CheckPasswordSignInAsync(user, loginDto.Password, false); - for (int i = 0; i < computedHash.Length; i++) - { - if (computedHash[i] != user.PasswordHash[i]) return Unauthorized("Invalid password"); - } + if (!result.Succeeded) return Unauthorized(); // Update LastActive on account user.LastActive = DateTime.Now; @@ -81,14 +82,14 @@ namespace API.Controllers return new UserDto() { Username = user.UserName, - Token = _tokenService.CreateToken(user), + Token = await _tokenService.CreateToken(user), IsAdmin = user.IsAdmin }; } private async Task UserExists(string username) { - return await _context.Users.AnyAsync(user => user.UserName == username.ToLower()); + return await _userManager.Users.AnyAsync(user => user.UserName == username.ToLower()); } } } \ No newline at end of file diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 2ff43ec52..93e3cdf06 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -100,8 +100,5 @@ namespace API.Controllers return BadRequest("Not Implemented"); } - - - } } \ No newline at end of file diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 92ce19955..e29f5758a 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -1,17 +1,36 @@ using System; using API.Entities; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace API.Data { - public class DataContext : DbContext + public class DataContext : IdentityDbContext, AppUserRole, IdentityUserLogin, + IdentityRoleClaim, IdentityUserToken> { public DataContext(DbContextOptions options) : base(options) { } - - public DbSet Users { get; set; } public DbSet Library { get; set; } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.Entity() + .HasMany(ur => ur.UserRoles) + .WithOne(u => u.User) + .HasForeignKey(ur => ur.UserId) + .IsRequired(); + + builder.Entity() + .HasMany(ur => ur.UserRoles) + .WithOne(u => u.Role) + .HasForeignKey(ur => ur.RoleId) + .IsRequired(); + } } } \ No newline at end of file diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index c19ace39f..fd6f137ac 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -16,37 +16,127 @@ namespace API.Data.Migrations modelBuilder .HasAnnotation("ProductVersion", "5.0.1"); + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + modelBuilder.Entity("API.Entities.AppUser", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + b.Property("Created") .HasColumnType("TEXT"); + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + b.Property("IsAdmin") .HasColumnType("INTEGER"); b.Property("LastActive") .HasColumnType("TEXT"); + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + b.Property("PasswordHash") .HasColumnType("BLOB"); b.Property("PasswordSalt") .HasColumnType("BLOB"); + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + b.Property("RowVersion") .IsConcurrencyToken() .HasColumnType("INTEGER"); + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + b.Property("UserName") + .HasMaxLength(256) .HasColumnType("TEXT"); b.HasKey("Id"); - b.ToTable("Users"); + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); }); modelBuilder.Entity("API.Entities.FolderPath", b => @@ -103,6 +193,109 @@ namespace API.Data.Migrations b.ToTable("AppUserLibrary"); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + modelBuilder.Entity("API.Entities.FolderPath", b => { b.HasOne("API.Entities.Library", "Library") @@ -129,6 +322,52 @@ namespace API.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("UserRoles"); + }); + modelBuilder.Entity("API.Entities.Library", b => { b.Navigation("Folders"); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs new file mode 100644 index 000000000..4bc4ebbc6 --- /dev/null +++ b/API/Data/Seed.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Entities; +using Microsoft.AspNetCore.Identity; + +namespace API.Data +{ + public class Seed + { + public static async Task SeedRoles(RoleManager roleManager) + { + var roles = new List + { + new AppRole {Name = "Admin"}, + new AppRole {Name = "Pleb"} + }; + + foreach (var role in roles) + { + await roleManager.CreateAsync(role); + } + } + } +} \ No newline at end of file diff --git a/API/Entities/AppRole.cs b/API/Entities/AppRole.cs new file mode 100644 index 000000000..8c0d07f96 --- /dev/null +++ b/API/Entities/AppRole.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Identity; + +namespace API.Entities +{ + public class AppRole : IdentityRole + { + public ICollection UserRoles { get; set; } + } +} \ No newline at end of file diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index a67272ccd..ea7ad0245 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -1,18 +1,13 @@ using System; -using System.Collections; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using API.Entities.Interfaces; +using Microsoft.AspNetCore.Identity; namespace API.Entities { - public class AppUser : IHasConcurrencyToken + public class AppUser : IdentityUser { - public int Id { get; set; } - public string UserName { get; set; } - public byte[] PasswordHash { get; set; } - public byte[] PasswordSalt { get; set; } public DateTime Created { get; set; } = DateTime.Now; public DateTime LastActive { get; set; } public bool IsAdmin { get; set; } @@ -20,6 +15,8 @@ namespace API.Entities [ConcurrencyCheck] public uint RowVersion { get; set; } + + public ICollection UserRoles { get; set; } public void OnSavingChanges() { diff --git a/API/Entities/AppUserRole.cs b/API/Entities/AppUserRole.cs new file mode 100644 index 000000000..b4c73f87e --- /dev/null +++ b/API/Entities/AppUserRole.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Identity; + +namespace API.Entities +{ + public class AppUserRole : IdentityUserRole + { + public AppUser User { get; set; } + public AppRole Role { get; set; } + } +} \ No newline at end of file diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 386efc0f2..9669c6822 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -1,6 +1,8 @@ -using System.Collections; -using System.Text; +using System.Text; +using API.Data; +using API.Entities; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; @@ -11,6 +13,17 @@ namespace API.Extensions { public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config) { + services.AddIdentityCore(opt => + { + // Change password / signin requirements here + opt.Password.RequireNonAlphanumeric = false; + }) + .AddRoles() + .AddRoleManager>() + .AddSignInManager>() + .AddRoleValidator>() + .AddEntityFrameworkStores(); + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 4e2f2a100..4e203ab04 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -19,6 +19,8 @@ namespace API.Helpers CreateMap() .AfterMap((ps, pst, context) => context.Mapper.Map(ps.Libraries, pst.Libraries)); + + CreateMap(); } } } \ No newline at end of file diff --git a/API/Interfaces/ITokenService.cs b/API/Interfaces/ITokenService.cs index e721d9ade..042426964 100644 --- a/API/Interfaces/ITokenService.cs +++ b/API/Interfaces/ITokenService.cs @@ -1,9 +1,10 @@ -using API.Entities; +using System.Threading.Tasks; +using API.Entities; namespace API.Interfaces { public interface ITokenService { - string CreateToken(AppUser user); + Task CreateToken(AppUser user); } } \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index f3a23a6c1..7ed8e7175 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,7 +1,9 @@ using System; using System.Threading.Tasks; using API.Data; +using API.Entities; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -21,8 +23,10 @@ namespace API try { var context = services.GetRequiredService(); + var roleManager = services.GetRequiredService>(); // Apply all migrations on startup await context.Database.MigrateAsync(); + await Seed.SeedRoles(roleManager); } catch (Exception ex) { diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index 195af02d2..45673dae8 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; +using System.Linq; using System.Security.Claims; using System.Text; +using System.Threading.Tasks; using API.Entities; using API.Interfaces; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.IdentityModel.Tokens; using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; @@ -14,19 +17,25 @@ namespace API.Services { public class TokenService : ITokenService { + private readonly UserManager _userManager; private readonly SymmetricSecurityKey _key; - public TokenService(IConfiguration config) + public TokenService(IConfiguration config, UserManager userManager) { + _userManager = userManager; _key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])); } - public string CreateToken(AppUser user) + public async Task CreateToken(AppUser user) { var claims = new List { new Claim(JwtRegisteredClaimNames.NameId, user.UserName) }; + + var roles = await _userManager.GetRolesAsync(user); + + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); var creds = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature);