diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml new file mode 100644 index 000000000..8a48bfd51 --- /dev/null +++ b/.github/workflows/dotnet-core.yml @@ -0,0 +1,25 @@ +name: .NET Core + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 5.0.100 + - name: Install dependencies + run: dotnet restore + - name: Build + run: dotnet build --configuration Release --no-restore + - name: Test + run: dotnet test --no-restore --verbosity normal diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs new file mode 100644 index 000000000..d64a2bab6 --- /dev/null +++ b/API/Constants/PolicyConstants.cs @@ -0,0 +1,8 @@ +namespace API.Constants +{ + public static class PolicyConstants + { + public static readonly string AdminRole = "Admin"; + public static readonly string PlebRole = "Pleb"; + } +} \ No newline at end of file diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index a007bfc6c..3610925e9 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using API.Constants; using API.DTOs; using API.Entities; using API.Interfaces; @@ -37,7 +38,6 @@ namespace API.Controllers [HttpPost("register")] public async Task> Register(RegisterDto registerDto) { - _logger.LogInformation("Username: " + registerDto.Password); if (await UserExists(registerDto.Username)) { return BadRequest("Username is taken."); @@ -48,16 +48,18 @@ namespace API.Controllers var result = await _userManager.CreateAsync(user, registerDto.Password); if (!result.Succeeded) return BadRequest(result.Errors); + - var roleResult = await _userManager.AddToRoleAsync(user, "Pleb"); + // TODO: Need a way to store Roles in enum and configure from there + var role = registerDto.IsAdmin ? PolicyConstants.AdminRole : PolicyConstants.PlebRole; + var roleResult = await _userManager.AddToRoleAsync(user, role); if (!roleResult.Succeeded) return BadRequest(result.Errors); - return new UserDto() + return new UserDto { Username = user.UserName, Token = await _tokenService.CreateToken(user), - IsAdmin = user.IsAdmin }; } @@ -79,11 +81,10 @@ namespace API.Controllers _userRepository.Update(user); await _userRepository.SaveAllAsync(); - return new UserDto() + return new UserDto { Username = user.UserName, - Token = await _tokenService.CreateToken(user), - IsAdmin = user.IsAdmin + Token = await _tokenService.CreateToken(user) }; } diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs index 155a53bfa..173961a48 100644 --- a/API/Controllers/AdminController.cs +++ b/API/Controllers/AdminController.cs @@ -1,5 +1,10 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; using API.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace API.Controllers @@ -7,17 +12,22 @@ namespace API.Controllers public class AdminController : BaseApiController { private readonly IUserRepository _userRepository; + private readonly UserManager _userManager; - public AdminController(IUserRepository userRepository) + public AdminController(IUserRepository userRepository, UserManager userManager) { _userRepository = userRepository; + _userManager = userManager; } - [HttpGet] + [HttpGet("exists")] public async Task> AdminExists() { - return await _userRepository.AdminExists(); + var users = await _userManager.GetUsersInRoleAsync("Admin"); + return users.Count > 0; } + + } diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 93e3cdf06..42fa27659 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -42,22 +42,20 @@ namespace API.Controllers /// /// /// + [Authorize(Policy = "RequireAdminRole")] [HttpGet("list")] public ActionResult> GetDirectories(string path) { - // TODO: We need some sort of validation other than our auth layer - _logger.Log(LogLevel.Debug, "Listing Directories for " + path); - if (string.IsNullOrEmpty(path)) { return Ok(Directory.GetLogicalDrives()); } - if (!Directory.Exists(@path)) return BadRequest("This is not a valid path"); + if (!Directory.Exists(path)) return BadRequest("This is not a valid path"); return Ok(_directoryService.ListDirectory(path)); } - + [HttpGet] public async Task>> GetLibraries() { @@ -77,14 +75,13 @@ namespace API.Controllers // return Ok(await _libraryRepository.GetLibrariesForUserAsync(user)); // } + [Authorize(Policy = "RequireAdminRole")] [HttpPut("update-for")] public async Task> UpdateLibrary(UpdateLibraryDto updateLibraryDto) { - // TODO: Only admins can do this var user = await _userRepository.GetUserByUsernameAsync(updateLibraryDto.Username); if (user == null) return BadRequest("Could not validate user"); - if (!user.IsAdmin) return Unauthorized("Only admins are permitted"); user.Libraries = new List(); diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 67039cce2..3b546b21f 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -24,12 +24,6 @@ namespace API.Controllers _userRepository = userRepository; _libraryRepository = libraryRepository; } - - [HttpGet] - public async Task>> GetUsers() - { - return Ok(await _userRepository.GetMembersAsync()); - } [HttpPost("add-library")] public async Task AddLibrary(CreateLibraryDto createLibraryDto) @@ -72,7 +66,27 @@ namespace API.Controllers return BadRequest("Not implemented"); } - + [Authorize(Policy = "RequireAdminRole")] + [HttpDelete("delete-user")] + public async Task DeleteUser(string username) + { + var user = await _userRepository.GetUserByUsernameAsync(username); + _userRepository.Delete(user); + + if (await _userRepository.SaveAllAsync()) + { + return Ok(); + } + + return BadRequest("Could not delete the user."); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpGet] + public async Task>> GetUsers() + { + return Ok(await _userRepository.GetMembersAsync()); + } } } \ No newline at end of file diff --git a/API/DTOs/MemberDto.cs b/API/DTOs/MemberDto.cs index 38ecbfcc3..6f09f1fc3 100644 --- a/API/DTOs/MemberDto.cs +++ b/API/DTOs/MemberDto.cs @@ -14,7 +14,7 @@ namespace API.DTOs public string Username { get; set; } public DateTime Created { get; set; } public DateTime LastActive { get; set; } - public bool IsAdmin { get; set; } public IEnumerable Libraries { get; set; } + public IEnumerable Roles { get; set; } } } \ No newline at end of file diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index c8b97abb6..eb0bf6bf2 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -2,8 +2,7 @@ { public class UserDto { - public string Username { get; set; } - public string Token { get; set; } - public bool IsAdmin { get; set; } + public string Username { get; init; } + public string Token { get; init; } } } \ No newline at end of file diff --git a/API/Data/Migrations/20201224155621_MiscCleanup.Designer.cs b/API/Data/Migrations/20201224155621_MiscCleanup.Designer.cs new file mode 100644 index 000000000..8ae8c597a --- /dev/null +++ b/API/Data/Migrations/20201224155621_MiscCleanup.Designer.cs @@ -0,0 +1,377 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20201224155621_MiscCleanup")] + partial class MiscCleanup + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + 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("TEXT"); + + 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.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 => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + 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") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20201224155621_MiscCleanup.cs b/API/Data/Migrations/20201224155621_MiscCleanup.cs new file mode 100644 index 000000000..20e0a4dc9 --- /dev/null +++ b/API/Data/Migrations/20201224155621_MiscCleanup.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class MiscCleanup : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordSalt", + table: "AspNetUsers"); + + migrationBuilder.AlterColumn( + name: "PasswordHash", + table: "AspNetUsers", + type: "TEXT", + nullable: true, + oldClrType: typeof(byte[]), + oldType: "BLOB", + oldNullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "PasswordHash", + table: "AspNetUsers", + type: "BLOB", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "PasswordSalt", + table: "AspNetUsers", + type: "BLOB", + nullable: true); + } + } +} diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 4bc4ebbc6..c03407e95 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using API.Constants; using API.Entities; using Microsoft.AspNetCore.Identity; @@ -11,8 +12,8 @@ namespace API.Data { var roles = new List { - new AppRole {Name = "Admin"}, - new AppRole {Name = "Pleb"} + new AppRole {Name = PolicyConstants.AdminRole}, + new AppRole {Name = PolicyConstants.PlebRole} }; foreach (var role in roles) diff --git a/API/Data/UserRepository.cs b/API/Data/UserRepository.cs index 3ecf5578e..6c63d31b9 100644 --- a/API/Data/UserRepository.cs +++ b/API/Data/UserRepository.cs @@ -6,6 +6,7 @@ using API.Entities; using API.Interfaces; using AutoMapper; using AutoMapper.QueryableExtensions; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace API.Data @@ -14,11 +15,13 @@ namespace API.Data { private readonly DataContext _context; private readonly IMapper _mapper; + private readonly UserManager _userManager; - public UserRepository(DataContext context, IMapper mapper) + public UserRepository(DataContext context, IMapper mapper, UserManager userManager) { _context = context; _mapper = mapper; + _userManager = userManager; } public void Update(AppUser user) @@ -26,6 +29,11 @@ namespace API.Data _context.Entry(user).State = EntityState.Modified; } + public void Delete(AppUser user) + { + _context.Users.Remove(user); + } + public async Task SaveAllAsync() { return await _context.SaveChangesAsync() > 0; @@ -49,9 +57,26 @@ namespace API.Data public async Task> GetMembersAsync() { - return await _context.Users.Include(x => x.Libraries) + return await _userManager.Users .Include(x => x.Libraries) - .ProjectTo(_mapper.ConfigurationProvider) + .Include(r => r.UserRoles) + .ThenInclude(r => r.Role) + .OrderBy(u => u.UserName) + .Select(u => new MemberDto + { + Id = u.Id, + Username = u.UserName, + Created = u.Created, + LastActive = u.LastActive, + Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), + Libraries = u.Libraries.Select(l => new LibraryDto + { + Name = l.Name, + CoverImage = l.CoverImage, + Type = l.Type, + Folders = l.Folders.Select(x => x.Path).ToList() + }).ToList() + }) .ToListAsync(); } @@ -62,11 +87,6 @@ namespace API.Data .ProjectTo(_mapper.ConfigurationProvider) .SingleOrDefaultAsync(); } - - public async Task AdminExists() - { - return await _context.Users.AnyAsync(x => x.IsAdmin); - - } + } } \ No newline at end of file diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 9669c6822..2d2a235f5 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -1,4 +1,5 @@ using System.Text; +using API.Constants; using API.Data; using API.Entities; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -35,6 +36,11 @@ namespace API.Extensions ValidateAudience = false }; }); + services.AddAuthorization(opt => + { + opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole)); + }); + return services; } } diff --git a/API/Interfaces/IUserRepository.cs b/API/Interfaces/IUserRepository.cs index 4ed10236f..601913c89 100644 --- a/API/Interfaces/IUserRepository.cs +++ b/API/Interfaces/IUserRepository.cs @@ -15,6 +15,6 @@ namespace API.Interfaces Task GetUserByUsernameAsync(string username); Task> GetMembersAsync(); Task GetMemberAsync(string username); - Task AdminExists(); + public void Delete(AppUser user); } } \ No newline at end of file diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 1b333b74c..49de3db48 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -1,5 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; +using System.Linq; using System.Threading.Tasks; using API.Interfaces; @@ -7,15 +10,22 @@ namespace API.Services { public class DirectoryService : IDirectoryService { + /// + /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. + /// + /// Absolute path + /// List of folder names public IEnumerable ListDirectory(string rootPath) { - // TODO: Filter out Hidden and System folders - // DirectoryInfo di = new DirectoryInfo(@path); - // var dirs = di.GetDirectories() - // .Where(dir => (dir.Attributes & FileAttributes.Hidden & FileAttributes.System) == 0).ToImmutableList(); - // - - return Directory.GetDirectories(@rootPath); + // TODO: Put some checks in here along with API to ensure that we aren't passed a file, folder exists, etc. + + var di = new DirectoryInfo(rootPath); + var dirs = di.GetDirectories() + .Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System))) + .Select(d => d.Name).ToImmutableList(); + + + return dirs; } } } \ No newline at end of file