From 0ad552c334a205aae0bed7dec0b10523b1aa57e8 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 26 Oct 2021 19:26:41 +0100 Subject: [PATCH] added auth handlers, added role seed, separate username/email, implemented some web endpoints --- Selector.Model/ApplicationDbContext.cs | 2 + Selector.Model/ApplicationUser.cs | 29 ++++++ Selector.Model/Authorisation/Constants.cs | 36 ++++++++ .../User/UserIsAdminAuthHandler.cs | 34 +++++++ .../User/UserIsSelfAuthHandler.cs | 48 ++++++++++ .../Watcher/WatcherIsAdminAuthHandler.cs | 34 +++++++ .../Watcher/WatcherIsOwnerAuthHandler.cs | 49 ++++++++++ Selector.Model/SeedData.cs | 33 +++++++ Selector.Model/Watcher.cs | 2 + .../Areas/Identity/Pages/Account/Login.cshtml | 6 +- .../Identity/Pages/Account/Login.cshtml.cs | 5 +- .../Identity/Pages/Account/Register.cshtml | 5 + .../Identity/Pages/Account/Register.cshtml.cs | 6 +- Selector.Web/Controller/BaseAuthController.cs | 34 +++++++ Selector.Web/Controller/UserController.cs | 91 ++++++++++++++----- Selector.Web/Controller/WatcherController.cs | 66 ++++++++++---- Selector.Web/Pages/Index.cshtml | 1 - Selector.Web/Startup.cs | 7 ++ 18 files changed, 437 insertions(+), 51 deletions(-) create mode 100644 Selector.Model/Authorisation/Constants.cs create mode 100644 Selector.Model/Authorisation/User/UserIsAdminAuthHandler.cs create mode 100644 Selector.Model/Authorisation/User/UserIsSelfAuthHandler.cs create mode 100644 Selector.Model/Authorisation/Watcher/WatcherIsAdminAuthHandler.cs create mode 100644 Selector.Model/Authorisation/Watcher/WatcherIsOwnerAuthHandler.cs create mode 100644 Selector.Model/SeedData.cs create mode 100644 Selector.Web/Controller/BaseAuthController.cs diff --git a/Selector.Model/ApplicationDbContext.cs b/Selector.Model/ApplicationDbContext.cs index f2cdf2a..b0211c0 100644 --- a/Selector.Model/ApplicationDbContext.cs +++ b/Selector.Model/ApplicationDbContext.cs @@ -32,6 +32,8 @@ namespace Selector.Model .HasOne(w => w.User) .WithMany(u => u.Watchers) .HasForeignKey(w => w.UserId); + + SeedData.Seed(modelBuilder); } } diff --git a/Selector.Model/ApplicationUser.cs b/Selector.Model/ApplicationUser.cs index 4f08c85..6feadc9 100644 --- a/Selector.Model/ApplicationUser.cs +++ b/Selector.Model/ApplicationUser.cs @@ -19,4 +19,33 @@ namespace Selector.Model public List Watchers { get; set; } } + + public class ApplicationUserDTO + { + public string UserName { get; set; } + public string Email { get; set; } + public string PhoneNumber { get; set; } + + public bool SpotifyIsLinked { get; set; } + public DateTime SpotifyLastRefresh { get; set; } + public int SpotifyTokenExpiry { get; set; } + public string SpotifyAccessToken { get; set; } + public string SpotifyRefreshToken { get; set; } + + public string LastFmUsername { get; set; } + + public static explicit operator ApplicationUserDTO(ApplicationUser user) => new() { + UserName = user.UserName, + Email = user.Email, + PhoneNumber = user.PhoneNumber, + + SpotifyIsLinked = user.SpotifyIsLinked, + SpotifyLastRefresh = user.SpotifyLastRefresh, + SpotifyTokenExpiry = user.SpotifyTokenExpiry, + SpotifyAccessToken = user.SpotifyAccessToken, + SpotifyRefreshToken = user.SpotifyRefreshToken, + + LastFmUsername = user.LastFmUsername + }; + } } \ No newline at end of file diff --git a/Selector.Model/Authorisation/Constants.cs b/Selector.Model/Authorisation/Constants.cs new file mode 100644 index 0000000..1c2e306 --- /dev/null +++ b/Selector.Model/Authorisation/Constants.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace Selector.Model.Authorisation +{ + public static class WatcherOperations + { + public static OperationAuthorizationRequirement Create = new() { Name = Constants.CreateOpName }; + public static OperationAuthorizationRequirement Read = new() { Name = Constants.ReadOpName }; + public static OperationAuthorizationRequirement Update = new() { Name = Constants.UpdateOpName }; + public static OperationAuthorizationRequirement Delete = new() { Name = Constants.DeleteOpName }; + } + + public static class UserOperations + { + public static OperationAuthorizationRequirement Create = new() { Name = Constants.CreateOpName }; + public static OperationAuthorizationRequirement Read = new() { Name = Constants.ReadOpName }; + public static OperationAuthorizationRequirement Update = new() { Name = Constants.UpdateOpName }; + public static OperationAuthorizationRequirement Delete = new() { Name = Constants.DeleteOpName }; + } + + public class Constants + { + public const string CreateOpName = "Create"; + public const string ReadOpName = "Read"; + public const string UpdateOpName = "Update"; + public const string DeleteOpName = "Delete"; + + public const string AdminRole = "Admin"; + } +} diff --git a/Selector.Model/Authorisation/User/UserIsAdminAuthHandler.cs b/Selector.Model/Authorisation/User/UserIsAdminAuthHandler.cs new file mode 100644 index 0000000..a592616 --- /dev/null +++ b/Selector.Model/Authorisation/User/UserIsAdminAuthHandler.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using Microsoft.AspNetCore.Identity; + +namespace Selector.Model.Authorisation +{ + public class UserIsAdminAuthHandler + : AuthorizationHandler + { + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + OperationAuthorizationRequirement requirement, + ApplicationUser resource + ) { + if (context.User == null || resource == null) + { + return Task.CompletedTask; + } + + if (context.User.IsInRole(Constants.AdminRole)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +} diff --git a/Selector.Model/Authorisation/User/UserIsSelfAuthHandler.cs b/Selector.Model/Authorisation/User/UserIsSelfAuthHandler.cs new file mode 100644 index 0000000..13693f7 --- /dev/null +++ b/Selector.Model/Authorisation/User/UserIsSelfAuthHandler.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using Microsoft.AspNetCore.Identity; + +namespace Selector.Model.Authorisation +{ + public class UserIsSelfAuthHandler + : AuthorizationHandler + { + private readonly UserManager userManager; + + public UserIsSelfAuthHandler(UserManager userManager) + { + this.userManager = userManager; + } + + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + OperationAuthorizationRequirement requirement, + ApplicationUser resource + ) { + if (context.User == null || resource == null) + { + return Task.CompletedTask; + } + + if (requirement.Name != Constants.ReadOpName && + requirement.Name != Constants.UpdateOpName && + requirement.Name != Constants.DeleteOpName) + { + return Task.CompletedTask; + } + + if (resource.Id == userManager.GetUserId(context.User)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +} diff --git a/Selector.Model/Authorisation/Watcher/WatcherIsAdminAuthHandler.cs b/Selector.Model/Authorisation/Watcher/WatcherIsAdminAuthHandler.cs new file mode 100644 index 0000000..ca50ee4 --- /dev/null +++ b/Selector.Model/Authorisation/Watcher/WatcherIsAdminAuthHandler.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using Microsoft.AspNetCore.Identity; + +namespace Selector.Model.Authorisation +{ + public class WatcherIsAdminAuthHandler + : AuthorizationHandler + { + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + OperationAuthorizationRequirement requirement, + Watcher resource + ) { + if (context.User == null || resource == null) + { + return Task.CompletedTask; + } + + if (context.User.IsInRole(Constants.AdminRole)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +} diff --git a/Selector.Model/Authorisation/Watcher/WatcherIsOwnerAuthHandler.cs b/Selector.Model/Authorisation/Watcher/WatcherIsOwnerAuthHandler.cs new file mode 100644 index 0000000..a79d9be --- /dev/null +++ b/Selector.Model/Authorisation/Watcher/WatcherIsOwnerAuthHandler.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using Microsoft.AspNetCore.Identity; + +namespace Selector.Model.Authorisation +{ + public class WatcherIsOwnerAuthHandler + : AuthorizationHandler + { + private readonly UserManager userManager; + + public WatcherIsOwnerAuthHandler(UserManager userManager) + { + this.userManager = userManager; + } + + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + OperationAuthorizationRequirement requirement, + Watcher resource + ) { + if (context.User == null || resource == null) + { + return Task.CompletedTask; + } + + if (requirement.Name != Constants.CreateOpName && + requirement.Name != Constants.ReadOpName && + requirement.Name != Constants.UpdateOpName && + requirement.Name != Constants.DeleteOpName) + { + return Task.CompletedTask; + } + + if (resource.UserId == userManager.GetUserId(context.User)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +} diff --git a/Selector.Model/SeedData.cs b/Selector.Model/SeedData.cs new file mode 100644 index 0000000..9a65b60 --- /dev/null +++ b/Selector.Model/SeedData.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +using Selector.Model.Authorisation; + +namespace Selector.Model +{ + public static class SeedData + { + public static void Seed(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasData( + GetRole(Constants.AdminRole, "00c64c0a-3387-4933-9575-83443fa9092b") + ); + } + + public static IdentityRole GetRole(string name, string id) + { + return new IdentityRole + { + Name = name, + NormalizedName = name.ToUpperInvariant(), + Id = id + }; + } + } +} diff --git a/Selector.Model/Watcher.cs b/Selector.Model/Watcher.cs index d718905..c1b5b02 100644 --- a/Selector.Model/Watcher.cs +++ b/Selector.Model/Watcher.cs @@ -8,7 +8,9 @@ namespace Selector.Model { public int Id { get; set; } + [Required] public string UserId { get; set; } + [Required] public ApplicationUser User { get; set; } public WatcherType Type { get; set; } diff --git a/Selector.Web/Areas/Identity/Pages/Account/Login.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Login.cshtml index 72a567f..a9e502f 100644 --- a/Selector.Web/Areas/Identity/Pages/Account/Login.cshtml +++ b/Selector.Web/Areas/Identity/Pages/Account/Login.cshtml @@ -14,9 +14,9 @@
- - - + + +
diff --git a/Selector.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Login.cshtml.cs index d6e76d5..8d0bda8 100644 --- a/Selector.Web/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/Selector.Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -45,8 +45,7 @@ namespace Selector.Web.Areas.Identity.Pages.Account public class InputModel { [Required] - [EmailAddress] - public string Email { get; set; } + public string Username { get; set; } [Required] [DataType(DataType.Password)] @@ -83,7 +82,7 @@ namespace Selector.Web.Areas.Identity.Pages.Account { // This doesn't count login failures towards account lockout // To enable password failures to trigger account lockout, set lockoutOnFailure: true - var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); + var result = await _signInManager.PasswordSignInAsync(Input.Username, Input.Password, Input.RememberMe, lockoutOnFailure: false); if (result.Succeeded) { _logger.LogInformation("User logged in."); diff --git a/Selector.Web/Areas/Identity/Pages/Account/Register.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Register.cshtml index 96e6a9a..e245c1d 100644 --- a/Selector.Web/Areas/Identity/Pages/Account/Register.cshtml +++ b/Selector.Web/Areas/Identity/Pages/Account/Register.cshtml @@ -12,6 +12,11 @@

Create a new account.


+
+ + + +
diff --git a/Selector.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index 1fee536..142331f 100644 --- a/Selector.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/Selector.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -47,6 +47,10 @@ namespace Selector.Web.Areas.Identity.Pages.Account public class InputModel { + [Required] + [Display(Name = "Username")] + public string Username { get; set; } + [Required] [EmailAddress] [Display(Name = "Email")] @@ -76,7 +80,7 @@ namespace Selector.Web.Areas.Identity.Pages.Account ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); if (ModelState.IsValid) { - var user = new ApplicationUser { UserName = Input.Email, Email = Input.Email }; + var user = new ApplicationUser { UserName = Input.Username, Email = Input.Email }; var result = await _userManager.CreateAsync(user, Input.Password); if (result.Succeeded) { diff --git a/Selector.Web/Controller/BaseAuthController.cs b/Selector.Web/Controller/BaseAuthController.cs new file mode 100644 index 0000000..58dbd2d --- /dev/null +++ b/Selector.Web/Controller/BaseAuthController.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; + +using Selector.Model; +using Microsoft.Extensions.Logging; + +namespace Selector.Web.Controller { + + public class BaseAuthController: Microsoft.AspNetCore.Mvc.Controller + { + protected ApplicationDbContext Context { get; } + protected IAuthorizationService AuthorizationService { get; } + protected UserManager UserManager { get; } + protected ILogger Logger { get; } + + public BaseAuthController( + ApplicationDbContext context, + IAuthorizationService auth, + UserManager userManager, + ILogger logger + ) { + Context = context; + AuthorizationService = auth; + UserManager = userManager; + Logger = logger; + } + } +} \ No newline at end of file diff --git a/Selector.Web/Controller/UserController.cs b/Selector.Web/Controller/UserController.cs index 2e889c8..1d8a032 100644 --- a/Selector.Web/Controller/UserController.cs +++ b/Selector.Web/Controller/UserController.cs @@ -4,46 +4,91 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; -using Selector.Model; -namespace Selector.Web.Controller { - +using Selector.Model; +using Selector.Model.Authorisation; +using Microsoft.Extensions.Logging; + +namespace Selector.Web.Controller +{ + [ApiController] [Route("api/[controller]")] - public class UsersController { + public class UsersController : BaseAuthController + { + public UsersController( + ApplicationDbContext context, + IAuthorizationService auth, + UserManager userManager, + ILogger logger + ) : base(context, auth, userManager, logger) { } - private readonly ApplicationDbContext db; - - public UsersController(ApplicationDbContext context) - { - db = context; - } - - [HttpGet()] - public async Task>> Get(string username) + [HttpGet] + [Authorize(Roles = Constants.AdminRole)] + public async Task>> Get() { // TODO: Authorise - return await db.Users.ToListAsync(); + return await Context.Users.AsNoTracking().Select(u => (ApplicationUserDTO)u).ToListAsync(); } } [ApiController] [Route("api/[controller]")] - public class UserController { + public class UserController : BaseAuthController + { + public UserController( + ApplicationDbContext context, + IAuthorizationService auth, + UserManager userManager, + ILogger logger + ) : base(context, auth, userManager, logger) { } - private readonly ApplicationDbContext db; - - public UserController(ApplicationDbContext context) + [HttpGet] + public async Task> Get() { - db = context; + var userId = UserManager.GetUserId(User); + var user = await Context.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId); + + if (user is null) + { + Logger.LogWarning($"No user found for [{userId}], even though the 'me' route was used"); + return NotFound(); + } + + var isAuthed = await AuthorizationService.AuthorizeAsync(User, user, UserOperations.Read); + + if (!isAuthed.Succeeded) + { + Logger.LogWarning($"User [{user.UserName}] not authorised to view themselves?"); + return Unauthorized(); + } + + return (ApplicationUserDTO)user; } - [HttpGet("{username}")] - public async Task> Get(string username) + [HttpGet("{id}")] + public async Task> GetById(string id) { - // TODO: Implement - return await db.Users.SingleAsync(); + var usernameUpper = id.ToUpperInvariant(); + + var user = await Context.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id) + ?? await Context.Users.AsNoTracking().FirstOrDefaultAsync(u => u.NormalizedUserName == usernameUpper); + + if (user is null) + { + return NotFound(); + } + + var isAuthed = await AuthorizationService.AuthorizeAsync(User, user, UserOperations.Read); + + if (!isAuthed.Succeeded) + { + return Unauthorized(); + } + + return (ApplicationUserDTO)user; } } } \ No newline at end of file diff --git a/Selector.Web/Controller/WatcherController.cs b/Selector.Web/Controller/WatcherController.cs index 96369ce..4657cde 100644 --- a/Selector.Web/Controller/WatcherController.cs +++ b/Selector.Web/Controller/WatcherController.cs @@ -4,46 +4,72 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + using Selector.Model; +using Selector.Model.Authorisation; namespace Selector.Web.Controller { [ApiController] [Route("api/[controller]")] - public class WatchersController { - - private readonly ApplicationDbContext db; - - public WatchersController(ApplicationDbContext context) - { - db = context; - } + public class WatchersController : BaseAuthController + { + public WatchersController( + ApplicationDbContext context, + IAuthorizationService auth, + UserManager userManager, + ILogger logger + ) : base(context, auth, userManager, logger) { } [HttpGet] public async Task>> Get() { - // TODO: Authorise - return await db.Watcher.ToListAsync(); + var isAuthed = User.IsInRole(Constants.AdminRole); + + if(isAuthed) + { + return await Context.Watcher.AsNoTracking().ToListAsync(); + } + else + { + var userId = UserManager.GetUserId(User); + return await Context.Watcher.AsNoTracking().Where(w => w.UserId == userId).ToListAsync(); + } } } [ApiController] [Route("api/[controller]")] - public class WatcherController { - - private readonly ApplicationDbContext db; - - public WatcherController(ApplicationDbContext context) - { - db = context; - } + public class WatcherController : BaseAuthController + { + public WatcherController( + ApplicationDbContext context, + IAuthorizationService auth, + UserManager userManager, + ILogger logger + ) : base(context, auth, userManager, logger) { } [HttpGet("{id}")] public async Task> Get(int id) { - // TODO: Implement - return await db.Watcher.FirstAsync(); + var watcher = await Context.Watcher.AsNoTracking().FirstOrDefaultAsync(w => w.Id == id); + + if(watcher is null) + { + return NotFound(); + } + + var isAuthed = await AuthorizationService.AuthorizeAsync(User, watcher, WatcherOperations.Read); + + if(!isAuthed.Succeeded) + { + return Unauthorized(); + } + + return watcher; } } } \ No newline at end of file diff --git a/Selector.Web/Pages/Index.cshtml b/Selector.Web/Pages/Index.cshtml index dc093d0..190d9d5 100644 --- a/Selector.Web/Pages/Index.cshtml +++ b/Selector.Web/Pages/Index.cshtml @@ -6,7 +6,6 @@

Welcome

-

Learn about building Web apps with ASP.NET Core.

diff --git a/Selector.Web/Startup.cs b/Selector.Web/Startup.cs index f91241c..2249e6e 100644 --- a/Selector.Web/Startup.cs +++ b/Selector.Web/Startup.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Selector.Model; +using Selector.Model.Authorisation; namespace Selector.Web { @@ -80,6 +81,12 @@ namespace Selector.Web .RequireAuthenticatedUser() .Build(); }); + + services.AddScoped(); + services.AddSingleton(); + + services.AddScoped(); + services.AddSingleton(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.