diff --git a/src/Api/ConfigureServices.cs b/src/Api/ConfigureServices.cs index 9420dab1..0fd0aa2b 100644 --- a/src/Api/ConfigureServices.cs +++ b/src/Api/ConfigureServices.cs @@ -23,6 +23,7 @@ public static IServiceCollection AddApiServices(this IServiceCollection services builder.AllowAnyOrigin(); builder.AllowAnyHeader(); builder.AllowAnyMethod(); + builder.AllowCredentials(); }); }); diff --git a/src/Api/Controllers/AuthController.cs b/src/Api/Controllers/AuthController.cs index 5971cb2f..b0941458 100644 --- a/src/Api/Controllers/AuthController.cs +++ b/src/Api/Controllers/AuthController.cs @@ -59,8 +59,8 @@ public async Task Logout() if (!loggedOut) return Ok(); - RemoveJweToken(jweToken); - RemoveRefreshToken(refreshToken); + RemoveJweToken(); + RemoveRefreshToken(); return Ok(); } @@ -80,6 +80,23 @@ public async Task Refresh() return Ok(); } + [Authorize] + [HttpPost] + public async Task Validate() + { + var refreshToken = Request.Cookies[nameof(RefreshToken)]; + var jweToken = Request.Cookies["JweToken"]; + + var validated = await _identityService.Validate(jweToken!, refreshToken!); + + if (validated) + { + return Ok(); + } + + return Unauthorized(); + } + private void SetJweToken(SecurityToken jweToken) { var cookieOptions = new CookieOptions @@ -100,23 +117,13 @@ private void SetRefreshToken(RefreshTokenDto newRefreshToken) Response.Cookies.Append(nameof(RefreshToken), newRefreshToken.Token.ToString(), cookieOptions); } - private void RemoveJweToken(string jweToken) + private void RemoveJweToken() { - var cookieOptions = new CookieOptions - { - HttpOnly = true, - Expires = DateTimeOffset.FromUnixTimeSeconds(0) - }; - Response.Cookies.Append("JweToken", jweToken, cookieOptions); + Response.Cookies.Delete("JweToken"); } - private void RemoveRefreshToken(string newRefreshToken) + private void RemoveRefreshToken() { - var cookieOptions = new CookieOptions - { - HttpOnly = true, - Expires = DateTimeOffset.FromUnixTimeSeconds(0) - }; - Response.Cookies.Append(nameof(RefreshToken), newRefreshToken, cookieOptions); + Response.Cookies.Delete(nameof(RefreshToken)); } } \ No newline at end of file diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json index 70a1ba99..41b82277 100644 --- a/src/Api/appsettings.Development.json +++ b/src/Api/appsettings.Development.json @@ -9,6 +9,7 @@ "TokenLifetime": "00:20:00", "RefreshTokenLifetimeInDays": 3 }, + "Seed": true, "Serilog" : { "MinimumLevel" : { "Default": "Debug", diff --git a/src/Application/Common/Interfaces/IIdentityService.cs b/src/Application/Common/Interfaces/IIdentityService.cs index e5973425..20fab955 100644 --- a/src/Application/Common/Interfaces/IIdentityService.cs +++ b/src/Application/Common/Interfaces/IIdentityService.cs @@ -5,6 +5,7 @@ namespace Application.Common.Interfaces; public interface IIdentityService { + Task Validate(string token, string refreshToken); Task RefreshTokenAsync(string token, string refreshToken); Task<(AuthenticationResult AuthResult, UserDto UserCredentials)> LoginAsync(string email, string password); Task LogoutAsync(string token, string refreshToken); diff --git a/src/Application/Users/Commands/DisableUser/DisableUserCommand.cs b/src/Application/Users/Commands/DisableUser/DisableUserCommand.cs index c1ba7d09..505961e6 100644 --- a/src/Application/Users/Commands/DisableUser/DisableUserCommand.cs +++ b/src/Application/Users/Commands/DisableUser/DisableUserCommand.cs @@ -26,13 +26,18 @@ public async Task Handle(DisableUserCommand request, CancellationToken var user = await _context.Users.FirstOrDefaultAsync(x => x.Id == request.UserId, cancellationToken); if (user is null) { - throw new KeyNotFoundException("User does not exist"); + throw new KeyNotFoundException("User does not exist."); + } + + if (!user.IsActive) + { + throw new InvalidOperationException("User has already been disabled."); } user.IsActive = false; var result = _context.Users.Update(user); await _context.SaveChangesAsync(cancellationToken); - return _mapper.Map(result); + return _mapper.Map(result.Entity); } } \ No newline at end of file diff --git a/src/Infrastructure/Identity/Authentication/JweAuthenticationHandler.cs b/src/Infrastructure/Identity/Authentication/JweAuthenticationHandler.cs index 587a9fed..795d1585 100644 --- a/src/Infrastructure/Identity/Authentication/JweAuthenticationHandler.cs +++ b/src/Infrastructure/Identity/Authentication/JweAuthenticationHandler.cs @@ -46,14 +46,6 @@ protected override async Task HandleAuthenticateAsync() ? AuthenticateResult.Fail("Invalid token.") : AuthenticateResult.Success(new AuthenticationTicket(claimsPrincipal, Scheme.Name)); } - catch (SecurityTokenExpiredException ex) - { - return AuthenticateResult.Fail(ex); - } - catch (SecurityTokenKeyWrapException ex) - { - return AuthenticateResult.Fail(ex); - } catch (Exception ex) { return AuthenticateResult.Fail(ex); diff --git a/src/Infrastructure/Identity/IdentityService.cs b/src/Infrastructure/Identity/IdentityService.cs index 3ce80018..d1b36fac 100644 --- a/src/Infrastructure/Identity/IdentityService.cs +++ b/src/Infrastructure/Identity/IdentityService.cs @@ -39,6 +39,65 @@ public IdentityService(TokenValidationParameters tokenValidationParameters, IOpt _mapper = mapper; } + public async Task Validate(string token, string refreshToken) + { + var validatedToken = GetPrincipalFromToken(token); + + if (validatedToken is null) + { + return false; + } + + const string emailClaim = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"; + + var email = validatedToken.Claims.Single(y => y.Type.Equals(emailClaim)).Value; + + var user = await _context.Users.FirstOrDefaultAsync(x => + x.Username.Equals(email) + || x.Email!.Equals(email)); + + if (user is null) + { + return false; + } + + var expiryDateUnix = + long.Parse(validatedToken.Claims.Single(x => x.Type.Equals(JwtRegisteredClaimNames.Exp)).Value); + + var expiryDateTimeUtc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc) + .AddSeconds(expiryDateUnix); + + if (expiryDateTimeUtc < DateTime.UtcNow) + { + return false; + } + + var jti = validatedToken.Claims.Single(x => x.Type.Equals(JwtRegisteredClaimNames.Jti)).Value; + var storedRefreshToken = await _context.RefreshTokens.SingleOrDefaultAsync(x => x.Token.Equals(Guid.Parse(refreshToken))); + + if (storedRefreshToken is null) + { + return false; + } + + if (DateTime.UtcNow > storedRefreshToken.ExpiryDateTime.ToDateTimeUnspecified().ToUniversalTime()) + { + return false; + } + + if (storedRefreshToken.IsInvalidated) + { + return false; + } + + if (storedRefreshToken.IsUsed) + { + return false; + } + + return storedRefreshToken.JwtId.Equals(jti); + } + public async Task RefreshTokenAsync(string token, string refreshToken) { var validatedToken = GetPrincipalFromToken(token); diff --git a/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs b/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs index 15ec99c7..a6593d43 100644 --- a/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs +++ b/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs @@ -1,4 +1,10 @@ +using Application.Helpers; +using Application.Identity; +using Domain.Entities; +using Infrastructure.Shared; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using NodaTime; using Serilog; namespace Infrastructure.Persistence; @@ -7,10 +13,11 @@ public class ApplicationDbContextSeed { public static async Task Seed(ApplicationDbContext context, IConfiguration configuration, ILogger logger) { + if (!configuration.GetValue("Seed")) return; + try { - // Note: For later uses when I actually have a working hash function - // await TrySeedAsync(context, configuration); + await TrySeedAsync(context); } catch (Exception ex) { @@ -19,19 +26,44 @@ public static async Task Seed(ApplicationDbContext context, IConfiguration confi } } - private async Task TrySeedAsync(ApplicationDbContext context, IConfiguration configuration) + private static async Task TrySeedAsync(ApplicationDbContext context) { - // Note: uncomment this - // // Default roles - // var administratorRole = "Administrator"; - // - // // Default users - // var administrator = new User { Username = "admin", Email = "administrator@localhost", PasswordHash = }; - // - // if (context.Users.All(u => u.Username != administrator.Username)) - // { - // administrator.Role = administratorRole; - // await context.Users.AddAsync(administrator); - // } + var department = new Department() + { + Name = "Admin" + }; + + // Default users + var admin = new User + { + Username = "admin", + Email = "admin@profile.dev", + PasswordHash = SecurityUtil.Hash("admin"), + IsActive = true, + IsActivated = true, + Created = LocalDateTime.FromDateTime(DateTime.UtcNow), + Role = IdentityData.Roles.Admin, + }; + + if (context.Departments.All(u => u.Name != department.Name)) + { + await context.Departments.AddAsync(department); + if (context.Users.All(u => u.Username != admin.Username)) + { + admin.Department = department; + await context.Users.AddAsync(admin); + } + } + else + { + var departmentEntity = context.Departments.Single(x => x.Name.Equals(department.Name)); + if (context.Users.All(u => u.Username != admin.Username)) + { + admin.Department = departmentEntity; + await context.Users.AddAsync(admin); + } + } + + await context.SaveChangesAsync(); } } \ No newline at end of file diff --git a/tests/Application.Tests.Integration/BaseClassFixture.cs b/tests/Application.Tests.Integration/BaseClassFixture.cs index c7c8d8e2..ccfcc9bf 100644 --- a/tests/Application.Tests.Integration/BaseClassFixture.cs +++ b/tests/Application.Tests.Integration/BaseClassFixture.cs @@ -32,7 +32,7 @@ protected static async Task SendAsync(IRequest return await mediator.Send(request); } - protected void Remove(TEntity entity) where TEntity : BaseEntity + protected void Remove(TEntity entity) where TEntity : BaseEntity? { using var scope = _scopeFactory.CreateScope(); diff --git a/tests/Application.Tests.Integration/Departments/Queries/GetAllDepartmentsTests.cs b/tests/Application.Tests.Integration/Departments/Queries/GetAllDepartmentsTests.cs index 94025dd5..6bd82481 100644 --- a/tests/Application.Tests.Integration/Departments/Queries/GetAllDepartmentsTests.cs +++ b/tests/Application.Tests.Integration/Departments/Queries/GetAllDepartmentsTests.cs @@ -1,5 +1,6 @@ using Application.Common.Mappings; using Application.Departments.Queries.GetAllDepartments; +using Application.Identity; using Application.Users.Queries; using AutoMapper; using Bogus; @@ -22,6 +23,7 @@ public GetAllDepartmentsTests(CustomApiFactory apiFactory) : base(apiFactory) public async Task ShouldReturnDepartments_WhenDepartmentsExist() { // Arrange + var department = new Department() { Id = Guid.NewGuid(), @@ -50,6 +52,7 @@ public async Task ShouldReturnEmptyList_WhenNoDepartmentsExist() var result = await SendAsync(query); // Assert - result.Should().BeEmpty(); + result.Count().Should().Be(1); + result.First().Name.Should().Be(IdentityData.Roles.Admin); } } \ No newline at end of file