Skip to content
1 change: 1 addition & 0 deletions src/Api/ConfigureServices.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ public static IServiceCollection AddApiServices(this IServiceCollection services
builder.AllowAnyOrigin();
builder.AllowAnyHeader();
builder.AllowAnyMethod();
builder.AllowCredentials();
});
});

Expand Down
39 changes: 23 additions & 16 deletions src/Api/Controllers/AuthController.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,8 +59,8 @@ public async Task<IActionResult> Logout()

if (!loggedOut) return Ok();

RemoveJweToken(jweToken);
RemoveRefreshToken(refreshToken);
RemoveJweToken();
RemoveRefreshToken();

return Ok();
}
Expand All@@ -80,6 +80,23 @@ public async Task<IActionResult> Refresh()
return Ok();
}

[Authorize]
[HttpPost]
public async Task<IActionResult> 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
Expand All@@ -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));
}
}
1 change: 1 addition & 0 deletions src/Api/appsettings.Development.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@
"TokenLifetime": "00:20:00",
"RefreshTokenLifetimeInDays": 3
},
"Seed": true,
"Serilog" : {
"MinimumLevel" : {
"Default": "Debug",
Expand Down
1 change: 1 addition & 0 deletions src/Application/Common/Interfaces/IIdentityService.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ namespace Application.Common.Interfaces;

public interface IIdentityService
{
Task<bool> Validate(string token, string refreshToken);
Task<AuthenticationResult> RefreshTokenAsync(string token, string refreshToken);
Task<(AuthenticationResult AuthResult, UserDto UserCredentials)> LoginAsync(string email, string password);
Task<bool> LogoutAsync(string token, string refreshToken);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,13 +26,18 @@ public async Task<UserDto> 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<UserDto>(result);
return _mapper.Map<UserDto>(result.Entity);
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,14 +46,6 @@ protected override async Task<AuthenticateResult> 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);
Expand Down
59 changes: 59 additions & 0 deletions src/Infrastructure/Identity/IdentityService.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,6 +39,65 @@ public IdentityService(TokenValidationParameters tokenValidationParameters, IOpt
_mapper = mapper;
}

public async Task<bool> 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<AuthenticationResult> RefreshTokenAsync(string token, string refreshToken)
{
var validatedToken = GetPrincipalFromToken(token);
Expand Down
62 changes: 47 additions & 15 deletions src/Infrastructure/Persistence/ApplicationDbContextSeed.cs
Original file line numberDiff line numberDiff line change
@@ -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;
Expand All@@ -7,10 +13,11 @@ public class ApplicationDbContextSeed
{
public static async Task Seed(ApplicationDbContext context, IConfiguration configuration, ILogger logger)
{
if (!configuration.GetValue<bool>("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)
{
Expand All@@ -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();
}
}
2 changes: 1 addition & 1 deletion tests/Application.Tests.Integration/BaseClassFixture.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,7 @@ protected static async Task<TResponse> SendAsync<TResponse>(IRequest<TResponse>
return await mediator.Send(request);
}

protected void Remove<TEntity>(TEntity entity) where TEntity : BaseEntity
protected void Remove<TEntity>(TEntity entity) where TEntity : BaseEntity?
{
using var scope = _scopeFactory.CreateScope();

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
using Application.Common.Mappings;
using Application.Departments.Queries.GetAllDepartments;
using Application.Identity;
using Application.Users.Queries;
using AutoMapper;
using Bogus;
Expand All@@ -22,6 +23,7 @@ public GetAllDepartmentsTests(CustomApiFactory apiFactory) : base(apiFactory)
public async Task ShouldReturnDepartments_WhenDepartmentsExist()
{
// Arrange

var department = new Department()
{
Id = Guid.NewGuid(),
Expand DownExpand Up@@ -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);
}
}