diff --git a/src/Application/Interfaces/IJwtGenerator.cs b/src/Application/Interfaces/IJwtGenerator.cs deleted file mode 100644 index 2598324..0000000 --- a/src/Application/Interfaces/IJwtGenerator.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Domain.Entities; - -namespace Application.Interfaces; - -public interface IJwtGenerator -{ - string GenerateToken(AppUser user, string role); -} \ No newline at end of file diff --git a/src/Application/Interfaces/ITokenService.cs b/src/Application/Interfaces/ITokenService.cs new file mode 100644 index 0000000..05303db --- /dev/null +++ b/src/Application/Interfaces/ITokenService.cs @@ -0,0 +1,10 @@ +using Domain.Entities; + +namespace Application.Interfaces; + +public interface ITokenService +{ + string GenerateToken(AppUser user, string role); + Task IsTokenInvalidatedAsync(string token); + Task AddInvalidatedTokenAsync(string token); +} \ No newline at end of file diff --git a/src/Application/Interfaces/Services/IIdentityService.cs b/src/Application/Interfaces/Services/IIdentityService.cs index 20e6d3e..44c6280 100644 --- a/src/Application/Interfaces/Services/IIdentityService.cs +++ b/src/Application/Interfaces/Services/IIdentityService.cs @@ -12,4 +12,5 @@ public interface IIdentityService Task> Login(LoginUserRequest loginDto); Task ChangeRole(ChangeRoleRequest changeRoleRequest); Task> GetUsersAsync(); + Task Logout(string token); } \ No newline at end of file diff --git a/src/Application/Services/DomainService/IdentityService.cs b/src/Application/Services/DomainService/IdentityService.cs index b81781c..90f2e8e 100644 --- a/src/Application/Services/DomainService/IdentityService.cs +++ b/src/Application/Services/DomainService/IdentityService.cs @@ -16,14 +16,14 @@ public class IdentityService : IIdentityService { private readonly IUserManagerRepository _userManagerRepository; private readonly IRoleManagerRepository _roleManagerRepository; - private readonly IJwtGenerator _jwtGenerator; + private readonly ITokenService _tokenService; public IdentityService(IUserManagerRepository userManagerRepository, IRoleManagerRepository roleManagerRepository, - IJwtGenerator jwtGenerator) + ITokenService tokenService) { _userManagerRepository = userManagerRepository; _roleManagerRepository = roleManagerRepository; - _jwtGenerator = jwtGenerator; + _tokenService = tokenService; } public async Task> SignUpUser(CreateUserRequest createUserRequest) @@ -74,7 +74,7 @@ public async Task> Login(LoginUserRequest loginUserReq if (!succeed) return Result.Fail("Username/Email not found and/or password incorrect"); var role = await _userManagerRepository.GetRoleAsync(appUser); - var token = _jwtGenerator.GenerateToken(appUser, role); + var token = _tokenService.GenerateToken(appUser, role); return Result.Ok(appUser.ToLoginUserResponse(role, token)); } @@ -108,4 +108,10 @@ public async Task> GetUsersAsync() return userWithRoles; } + + public async Task Logout(string token) + { + await _tokenService.AddInvalidatedTokenAsync(token); + return Result.Ok(); + } } \ No newline at end of file diff --git a/src/Web/Controllers/IdentityController.cs b/src/Web/Controllers/IdentityController.cs index 8c20386..9a0cedf 100644 --- a/src/Web/Controllers/IdentityController.cs +++ b/src/Web/Controllers/IdentityController.cs @@ -88,4 +88,18 @@ public async Task GetUsersAsync() return Ok(appUsersWithRoles); } + + [HttpPost] + [Authorize] + public async Task Logout() + { + var token = Request.Headers["Authorization"].ToString().Replace("Bearer ", ""); + var result = await _identityService.Logout(token); + if (!result.Succeed) + { + return BadRequest(Errors.New(nameof(Logout), result.Message)); + } + + return Ok("Logged out successfully!"); + } } \ No newline at end of file diff --git a/src/Web/Identity/RequiresClaimAttribute.cs b/src/Web/Identity/RequiresClaimAttribute.cs index afc7735..656ee41 100644 --- a/src/Web/Identity/RequiresClaimAttribute.cs +++ b/src/Web/Identity/RequiresClaimAttribute.cs @@ -19,6 +19,12 @@ public void OnAuthorization(AuthorizationFilterContext context) { var user = context.HttpContext.User; + if (user.Identity is { IsAuthenticated: false }) + { + context.Result = new UnauthorizedResult(); + return; + } + var hasRequiredRole = _roles.Any(role => user.HasClaim(_claimName, role)); if (!hasRequiredRole) diff --git a/src/Web/Middleware/TokenValidationMiddleware.cs b/src/Web/Middleware/TokenValidationMiddleware.cs new file mode 100644 index 0000000..af1f68a --- /dev/null +++ b/src/Web/Middleware/TokenValidationMiddleware.cs @@ -0,0 +1,35 @@ +using Application.Interfaces; + +namespace Web.Middleware; + +public class TokenValidationMiddleware +{ + private readonly RequestDelegate _next; + private readonly IServiceProvider _serviceProvider; + + public TokenValidationMiddleware(RequestDelegate next, IServiceProvider serviceProvider) + { + _next = next; + _serviceProvider = serviceProvider; + } + + public async Task InvokeAsync(HttpContext context) + { + // Create a scope to resolve scoped services + using var scope = _serviceProvider.CreateScope(); + var tokenService = scope.ServiceProvider.GetRequiredService(); + + var authHeader = context.Request.Headers["Authorization"].FirstOrDefault(); + if (authHeader != null && authHeader.StartsWith("Bearer ")) + { + var token = authHeader.Substring("Bearer ".Length).Trim(); + if (await tokenService.IsTokenInvalidatedAsync(token)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + } + + await _next(context); + } +} \ No newline at end of file diff --git a/src/Web/Services/JwtGeneratorService.cs b/src/Web/Services/TokenService.cs similarity index 72% rename from src/Web/Services/JwtGeneratorService.cs rename to src/Web/Services/TokenService.cs index b36fc72..01cfb04 100644 --- a/src/Web/Services/JwtGeneratorService.cs +++ b/src/Web/Services/TokenService.cs @@ -1,45 +1,60 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using Application.Interfaces; -using Domain.Entities; -using Microsoft.IdentityModel.Tokens; -using Web.Identity; - -namespace Web.Services; - -public class JwtGeneratorService : IJwtGenerator -{ - private readonly IConfiguration _configuration; - private readonly SymmetricSecurityKey _symmetricSecurityKey; - public JwtGeneratorService(IConfiguration configuration) - { - _configuration = configuration; - _symmetricSecurityKey = new SymmetricSecurityKey( - System.Text.Encoding.UTF8.GetBytes(_configuration["JwtSettings:Key"]) - ); - } - public string GenerateToken(AppUser user, string role) - { - var claims = new List - { - new Claim(JwtRegisteredClaimNames.Sub, user.Email), - new Claim(Claims.UserId, user.Id), - new Claim(Claims.Role, role) - }; - - var credentials = new SigningCredentials(_symmetricSecurityKey, SecurityAlgorithms.HmacSha512Signature); - - var tokenDescriptor = new SecurityTokenDescriptor - { - Subject = new ClaimsIdentity(claims), - Expires = DateTime.Now.AddHours(8), - SigningCredentials = credentials, - Issuer = _configuration["JwtSettings:Issuer"], - Audience = _configuration["JwtSettings:Audience"] - }; - - var tokenHandler = new JwtSecurityTokenHandler(); - var token = tokenHandler.CreateToken(tokenDescriptor); - return tokenHandler.WriteToken(token); - } +using System.Collections.Concurrent; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Application.Interfaces; +using Domain.Entities; +using Microsoft.IdentityModel.Tokens; +using Web.Identity; + +namespace Web.Services; + +public class TokenService : ITokenService +{ + private readonly IConfiguration _configuration; + private readonly SymmetricSecurityKey _symmetricSecurityKey; + private static readonly ConcurrentDictionary _invalidatedTokens = new(); + + public TokenService(IConfiguration configuration) + { + _configuration = configuration; + _symmetricSecurityKey = new SymmetricSecurityKey( + System.Text.Encoding.UTF8.GetBytes(_configuration["JwtSettings:Key"]) + ); + } + + public string GenerateToken(AppUser user, string role) + { + var claims = new List + { + new Claim(JwtRegisteredClaimNames.Sub, user.Email), + new Claim(Claims.UserId, user.Id), + new Claim(Claims.Role, role) + }; + + var credentials = new SigningCredentials(_symmetricSecurityKey, SecurityAlgorithms.HmacSha512Signature); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.Now.AddHours(8), + SigningCredentials = credentials, + Issuer = _configuration["JwtSettings:Issuer"], + Audience = _configuration["JwtSettings:Audience"] + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + + public Task IsTokenInvalidatedAsync(string token) + { + return Task.FromResult(_invalidatedTokens.ContainsKey(token)); + } + + public Task AddInvalidatedTokenAsync(string token) + { + _invalidatedTokens[token] = DateTime.UtcNow; + return Task.CompletedTask; + } } \ No newline at end of file diff --git a/src/Web/Startup/MiddlewareExtensions.cs b/src/Web/Startup/MiddlewareExtensions.cs index cd6be51..420dd5c 100644 --- a/src/Web/Startup/MiddlewareExtensions.cs +++ b/src/Web/Startup/MiddlewareExtensions.cs @@ -1,4 +1,6 @@ -namespace Web.Startup; +using Web.Middleware; + +namespace Web.Startup; public static class MiddlewareExtensions { @@ -12,8 +14,12 @@ public static WebApplication UseMiddlewareServices(this WebApplication app) app.UseHttpsRedirection(); app.UseCors("AllowSpecificOrigins"); + + app.UseMiddleware(); + app.UseAuthentication(); app.UseAuthorization(); + app.MapControllers(); return app; diff --git a/src/Web/Startup/ServiceExtensions.DI.cs b/src/Web/Startup/ServiceExtensions.DI.cs index 0cc93e9..9d6374c 100644 --- a/src/Web/Startup/ServiceExtensions.DI.cs +++ b/src/Web/Startup/ServiceExtensions.DI.cs @@ -11,7 +11,7 @@ public static partial class ServiceExtensions { public static void AddApplicationServices(this IServiceCollection services) { - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/test/Application.UnitTests/Services/DomainService/IdentityServiceTests.cs b/test/Application.UnitTests/Services/DomainService/IdentityServiceTests.cs index 20fc129..5c3a097 100644 --- a/test/Application.UnitTests/Services/DomainService/IdentityServiceTests.cs +++ b/test/Application.UnitTests/Services/DomainService/IdentityServiceTests.cs @@ -18,15 +18,15 @@ public class IdentityServiceTests { private readonly IUserManagerRepository _userManagerRepository; private readonly IRoleManagerRepository _roleManagerRepository; - private readonly IJwtGenerator _jwtGenerator; + private readonly ITokenService _tokenService; private readonly IdentityService _identityService; public IdentityServiceTests() { _userManagerRepository = Substitute.For(); _roleManagerRepository = Substitute.For(); - _jwtGenerator = Substitute.For(); - _identityService = new IdentityService(_userManagerRepository, _roleManagerRepository, _jwtGenerator); + _tokenService = Substitute.For(); + _identityService = new IdentityService(_userManagerRepository, _roleManagerRepository, _tokenService); } // Signup Tests @@ -230,7 +230,7 @@ public async Task Login_WhenLoginSucceeds_ReturnsSuccessResult() .Returns(Task.FromResult(true)); _userManagerRepository.GetRoleAsync(appUser) .Returns(Task.FromResult(role)); - _jwtGenerator.GenerateToken(appUser, role) + _tokenService.GenerateToken(appUser, role) .Returns(token); // Act