added auth handlers, added role seed, separate username/email, implemented some web endpoints

This commit is contained in:
andy 2021-10-26 19:26:41 +01:00
parent 1f94b624d2
commit 0ad552c334
18 changed files with 437 additions and 51 deletions

View File

@ -32,6 +32,8 @@ namespace Selector.Model
.HasOne(w => w.User)
.WithMany(u => u.Watchers)
.HasForeignKey(w => w.UserId);
SeedData.Seed(modelBuilder);
}
}

View File

@ -19,4 +19,33 @@ namespace Selector.Model
public List<Watcher> 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
};
}
}

View File

@ -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";
}
}

View File

@ -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<OperationAuthorizationRequirement, ApplicationUser>
{
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;
}
}
}

View File

@ -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<OperationAuthorizationRequirement, ApplicationUser>
{
private readonly UserManager<ApplicationUser> userManager;
public UserIsSelfAuthHandler(UserManager<ApplicationUser> 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;
}
}
}

View File

@ -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<OperationAuthorizationRequirement, Watcher>
{
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;
}
}
}

View File

@ -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<OperationAuthorizationRequirement, Watcher>
{
private readonly UserManager<ApplicationUser> userManager;
public WatcherIsOwnerAuthHandler(UserManager<ApplicationUser> 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;
}
}
}

View File

@ -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<IdentityRole>().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
};
}
}
}

View File

@ -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; }

View File

@ -14,9 +14,9 @@
<hr />
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="Input.Email"></label>
<input asp-for="Input.Email" class="form-control" />
<span asp-validation-for="Input.Email" class="text-danger"></span>
<label asp-for="Input.Username"></label>
<input asp-for="Input.Username" class="form-control" />
<span asp-validation-for="Input.Username" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Input.Password"></label>

View File

@ -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.");

View File

@ -12,6 +12,11 @@
<h4>Create a new account.</h4>
<hr />
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="Input.Username"></label>
<input asp-for="Input.Username" class="form-control" />
<span asp-validation-for="Input.Username" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Input.Email"></label>
<input asp-for="Input.Email" class="form-control" />

View File

@ -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)
{

View File

@ -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<ApplicationUser> UserManager { get; }
protected ILogger<BaseAuthController> Logger { get; }
public BaseAuthController(
ApplicationDbContext context,
IAuthorizationService auth,
UserManager<ApplicationUser> userManager,
ILogger<BaseAuthController> logger
) {
Context = context;
AuthorizationService = auth;
UserManager = userManager;
Logger = logger;
}
}
}

View File

@ -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<ApplicationUser> userManager,
ILogger<UsersController> logger
) : base(context, auth, userManager, logger) { }
private readonly ApplicationDbContext db;
public UsersController(ApplicationDbContext context)
{
db = context;
}
[HttpGet()]
public async Task<ActionResult<IEnumerable<ApplicationUser>>> Get(string username)
[HttpGet]
[Authorize(Roles = Constants.AdminRole)]
public async Task<ActionResult<IEnumerable<ApplicationUserDTO>>> 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<ApplicationUser> userManager,
ILogger<UserController> logger
) : base(context, auth, userManager, logger) { }
private readonly ApplicationDbContext db;
public UserController(ApplicationDbContext context)
[HttpGet]
public async Task<ActionResult<ApplicationUserDTO>> 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<ActionResult<ApplicationUser>> Get(string username)
[HttpGet("{id}")]
public async Task<ActionResult<ApplicationUserDTO>> 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;
}
}
}

View File

@ -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<ApplicationUser> userManager,
ILogger<WatchersController> logger
) : base(context, auth, userManager, logger) { }
[HttpGet]
public async Task<ActionResult<IEnumerable<Watcher>>> 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<ApplicationUser> userManager,
ILogger<WatcherController> logger
) : base(context, auth, userManager, logger) { }
[HttpGet("{id}")]
public async Task<ActionResult<Watcher>> 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;
}
}
}

View File

@ -6,7 +6,6 @@
<div class="text-center">
<h1 class="display-4">Welcome</h1>
<p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
<partial name="_LoginPartial" />
</div>

View File

@ -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<IAuthorizationHandler, WatcherIsOwnerAuthHandler>();
services.AddSingleton<IAuthorizationHandler, WatcherIsAdminAuthHandler>();
services.AddScoped<IAuthorizationHandler, UserIsSelfAuthHandler>();
services.AddSingleton<IAuthorizationHandler, UserIsAdminAuthHandler>();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.