Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/Api/ConfigureServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ public static IServiceCollection AddApiServices(this IServiceCollection services
{
options.AddPolicy("AllowAllOrigins", builder =>
{
builder.AllowAnyOrigin();
builder.WithOrigins("http://localhost:3000");
builder.AllowAnyHeader();
builder.AllowAnyMethod();
builder.AllowCredentials();
});
});

Expand Down
39 changes: 23 additions & 16 deletions src/Api/Controllers/AuthController.cs
Original file line number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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);
}
}