From 934c0f2e840826912f25592825bc0f0bff321c90 Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Wed, 24 May 2023 20:42:41 +0700 Subject: [PATCH 01/10] feat: auth with refresh token and jwe using cookies scheme --- src/Api/ConfigureServices.cs | 2 + src/Api/Controllers/ApiControllerBase.cs | 2 + src/Api/Controllers/AuthController.cs | 109 +++++----- src/Api/Controllers/DepartmentsController.cs | 3 + src/Api/Controllers/DocumentsController.cs | 7 + src/Api/Controllers/FoldersController.cs | 3 + src/Api/Controllers/LockersController.cs | 3 + src/Api/Controllers/Payload/LoginModel.cs | 7 - .../Payload/Requests/LoginModel.cs | 7 + .../Payload/Requests/RefreshTokenRequest.cs | 7 + .../Responses/ApiAuthenticationResult.cs | 7 + src/Api/Controllers/RoomsController.cs | 4 + src/Api/Controllers/StaffsController.cs | 3 + src/Api/Controllers/UsersController.cs | 6 +- src/Api/Middlewares/ExceptionMiddleware.cs | 8 + src/Api/appsettings.Development.json | 4 +- src/Application/Application.csproj | 1 + .../Common/Interfaces/IIdentityService.cs | 5 +- .../Common/Models/AuthenticationResult.cs | 12 ++ .../Common/Models/Dtos/RefreshTokenDto.cs | 27 +++ .../GetDocumentById/GetDocumentByIdQuery.cs | 5 +- .../GetUsersByName/GetUsersByNameQuery.cs | 8 +- src/Domain/Entities/RefreshToken.cs | 16 ++ src/Infrastructure/ConfigureServices.cs | 37 +++- .../JweAuthenticationHandler.cs | 64 +++--- .../JweAuthenticationOptions.cs | 6 +- .../Authorization/RequiresClaimAttribute.cs | 25 --- .../Authorization/RequiresRoleAttribute.cs | 38 ++++ .../Identity/IdentityService.cs | 198 ++++++++++++++++++ .../Persistence/ApplicationDbContext.cs | 2 + .../RefreshTokenConfiguration.cs | 37 ++++ .../ApplicationDbContextModelSnapshot.cs | 58 ++++- src/Infrastructure/Shared/JweSettings.cs | 2 + .../Common/Mappings/MappingTests.cs | 2 + 34 files changed, 584 insertions(+), 141 deletions(-) delete mode 100644 src/Api/Controllers/Payload/LoginModel.cs create mode 100644 src/Api/Controllers/Payload/Requests/LoginModel.cs create mode 100644 src/Api/Controllers/Payload/Requests/RefreshTokenRequest.cs create mode 100644 src/Api/Controllers/Payload/Responses/ApiAuthenticationResult.cs create mode 100644 src/Application/Common/Models/AuthenticationResult.cs create mode 100644 src/Application/Common/Models/Dtos/RefreshTokenDto.cs create mode 100644 src/Domain/Entities/RefreshToken.cs delete mode 100644 src/Infrastructure/Identity/Authorization/RequiresClaimAttribute.cs create mode 100644 src/Infrastructure/Identity/Authorization/RequiresRoleAttribute.cs create mode 100644 src/Infrastructure/Identity/IdentityService.cs create mode 100644 src/Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs diff --git a/src/Api/ConfigureServices.cs b/src/Api/ConfigureServices.cs index 4d26774b..dd8e88a4 100644 --- a/src/Api/ConfigureServices.cs +++ b/src/Api/ConfigureServices.cs @@ -14,6 +14,8 @@ public static IServiceCollection AddApiServices(this IServiceCollection services services.AddControllers(opt => opt.Conventions.Add(new RouteTokenTransformerConvention(new SlugifyParameterTransformer()))); + services.AddHttpContextAccessor(); + services.AddCors(options => { options.AddPolicy("AllowAllOrigins", builder => diff --git a/src/Api/Controllers/ApiControllerBase.cs b/src/Api/Controllers/ApiControllerBase.cs index ef41d9ae..9509d8bc 100644 --- a/src/Api/Controllers/ApiControllerBase.cs +++ b/src/Api/Controllers/ApiControllerBase.cs @@ -1,8 +1,10 @@ using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers; +[Authorize] [ApiController] [Route("api/v1/[controller]")] public abstract class ApiControllerBase : ControllerBase diff --git a/src/Api/Controllers/AuthController.cs b/src/Api/Controllers/AuthController.cs index c5449818..d2cc7237 100644 --- a/src/Api/Controllers/AuthController.cs +++ b/src/Api/Controllers/AuthController.cs @@ -1,83 +1,80 @@ -using System.Security.Claims; -using System.Security.Cryptography; -using Api.Controllers.Payload; +using System.IdentityModel.Tokens.Jwt; +using Api.Controllers.Payload.Requests; using Application.Common.Interfaces; using Application.Common.Models; -using Application.Helpers; -using Infrastructure.Shared; +using Application.Common.Models.Dtos; +using Domain.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; -using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; namespace Api.Controllers; -[AllowAnonymous] [ApiController] -[Route("api/v1/[controller]")] +[Route("api/v1/[controller]/[action]")] public class AuthController : ControllerBase { - private readonly IApplicationDbContext _context; - private readonly JweSettings _jweSettings; - private readonly RSA _encryptionKey; - private readonly ECDsa _signingKey; + private readonly IIdentityService _identityService; - public AuthController(IApplicationDbContext context, IOptions jweSettings, ECDsa signingKey, RSA encryptionKey) + public AuthController(IIdentityService identityService) { - _context = context; - _signingKey = signingKey; - _encryptionKey = encryptionKey; - _jweSettings = jweSettings.Value; + _identityService = identityService; } - [HttpPost("[action]")] + [AllowAnonymous] + [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public ActionResult> Login([FromBody] LoginModel loginModel) + public async Task Login([FromBody] LoginModel loginModel) { - var user = _context.Users.FirstOrDefault(x => x.Username.Equals(loginModel.Username) - || x.Email.Equals(loginModel.Username)); - if (user is null) - { - return Unauthorized(); - } + var authResult = await _identityService.LoginAsync(loginModel.Email, loginModel.Password); + + SetRefreshToken(authResult.RefreshToken); + SetJweToken(authResult.Token); + + return Ok(); + } - if (!SecurityUtil.Hash(loginModel.Password).Equals(user.PasswordHash)) - { - return Unauthorized(); - } + [Authorize] + [HttpPost] + public ActionResult>> Logout() + { - var authClaims = new List - { - new(JwtRegisteredClaimNames.Sub, user.Username), - new(JwtRegisteredClaimNames.Email, user.Email), - new(JwtRegisteredClaimNames.Iat, Guid.NewGuid().ToString()), - new(ClaimTypes.Role, user.Role), - }; - var publicEncryptionKey = new RsaSecurityKey(_encryptionKey.ExportParameters(false)) {KeyId = _jweSettings.EncryptionKeyId}; - var privateSigningKey = new ECDsaSecurityKey(_signingKey) {KeyId = _jweSettings.SigningKeyId}; + return Ok(); + } - var tokenDescriptor = new SecurityTokenDescriptor() + [Authorize] + [HttpPost] + public async Task Refresh() + { + var refreshToken = Request.Cookies[nameof(RefreshToken)]; + var jweToken = Request.Cookies["JweToken"]; + + var authResult = await _identityService.RefreshTokenAsync(jweToken!, refreshToken!); + + SetRefreshToken(authResult.RefreshToken); + SetJweToken(authResult.Token); + + return Ok(); + } + + private void SetJweToken(SecurityToken jweToken) + { + var cookieOptions = new CookieOptions { - Subject = new ClaimsIdentity(authClaims), - SigningCredentials = - new SigningCredentials(privateSigningKey, SecurityAlgorithms.EcdsaSha256), - EncryptingCredentials = - new EncryptingCredentials(publicEncryptionKey, SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512) + HttpOnly = true, }; - - var handler = new JsonWebTokenHandler + var handler = new JwtSecurityTokenHandler(); + Response.Cookies.Append("JweToken", handler.WriteToken(jweToken), cookieOptions); + } + + private void SetRefreshToken(RefreshTokenDto newRefreshToken) + { + var cookieOptions = new CookieOptions { - TokenLifetimeInMinutes = 1 + HttpOnly = true, + Expires = newRefreshToken.ExpiryDateTime }; - - var token = handler.CreateToken(tokenDescriptor); - return Ok(new { - token, - Expires = DateTime.Now.AddMinutes(handler.TokenLifetimeInMinutes) - } - ); + Response.Cookies.Append(nameof(RefreshToken), newRefreshToken.Token.ToString(), cookieOptions); } } \ No newline at end of file diff --git a/src/Api/Controllers/DepartmentsController.cs b/src/Api/Controllers/DepartmentsController.cs index c21ee696..6a010c00 100644 --- a/src/Api/Controllers/DepartmentsController.cs +++ b/src/Api/Controllers/DepartmentsController.cs @@ -1,7 +1,9 @@ using Application.Common.Models; using Application.Departments.Commands.CreateDepartment; using Application.Departments.Queries.GetAllDepartments; +using Application.Identity; using Application.Users.Queries; +using Infrastructure.Identity.Authorization; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers; @@ -13,6 +15,7 @@ public class DepartmentsController : ApiControllerBase /// /// command parameter to create a department /// Result[DepartmentDto] + [RequiresRole(IdentityData.Roles.Admin)] [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] diff --git a/src/Api/Controllers/DocumentsController.cs b/src/Api/Controllers/DocumentsController.cs index 7be8984e..0b2cb80e 100644 --- a/src/Api/Controllers/DocumentsController.cs +++ b/src/Api/Controllers/DocumentsController.cs @@ -3,8 +3,11 @@ using Application.Departments.Commands.CreateDepartment; using Application.Documents.Commands.ImportDocument; using Application.Documents.Queries.GetAllDocumentsPaginated; +using Application.Documents.Queries.GetDocumentById; using Application.Documents.Queries.GetDocumentTypes; +using Application.Identity; using Application.Users.Queries; +using Infrastructure.Identity.Authorization; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers; @@ -17,21 +20,25 @@ public class DocumentsController : ApiControllerBase [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] + [RequiresRole(IdentityData.Roles.Admin, IdentityData.Roles.Staff)] public async Task>> ImportDocument([FromBody] ImportDocumentCommand command) { var result = await Mediator.Send(command); return Ok(Result.Succeed(result)); } + [RequiresRole(IdentityData.Roles.Admin, IdentityData.Roles.Staff)] [HttpGet("types")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>>> GetAllDocumentTypes() { var result = await Mediator.Send(new GetAllDocumentTypesQuery()); return Ok(Result>.Succeed(result)); } + [RequiresRole(IdentityData.Roles.Admin, IdentityData.Roles.Staff)] [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] diff --git a/src/Api/Controllers/FoldersController.cs b/src/Api/Controllers/FoldersController.cs index 2441b29c..b95bec35 100644 --- a/src/Api/Controllers/FoldersController.cs +++ b/src/Api/Controllers/FoldersController.cs @@ -1,12 +1,15 @@ using Application.Common.Models; using Application.Common.Models.Dtos.Physical; using Application.Folders.Commands.AddFolder; +using Application.Identity; +using Infrastructure.Identity.Authorization; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers; public class FoldersController : ApiControllerBase { + [RequiresRole(IdentityData.Roles.Staff)] [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] diff --git a/src/Api/Controllers/LockersController.cs b/src/Api/Controllers/LockersController.cs index 639fa7c8..a486b143 100644 --- a/src/Api/Controllers/LockersController.cs +++ b/src/Api/Controllers/LockersController.cs @@ -1,13 +1,16 @@ using Application.Common.Models; using Application.Common.Models.Dtos.Physical; +using Application.Identity; using Application.Lockers.Commands.AddLocker; using Application.Lockers.Commands.RemoveLocker; +using Infrastructure.Identity.Authorization; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers; public class LockersController : ApiControllerBase { + [RequiresRole(IdentityData.Roles.Staff)] [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] diff --git a/src/Api/Controllers/Payload/LoginModel.cs b/src/Api/Controllers/Payload/LoginModel.cs deleted file mode 100644 index d9262a51..00000000 --- a/src/Api/Controllers/Payload/LoginModel.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Api.Controllers.Payload; - -public class LoginModel -{ - public string Username { get; set; } - public string Password { get; set; } -} \ No newline at end of file diff --git a/src/Api/Controllers/Payload/Requests/LoginModel.cs b/src/Api/Controllers/Payload/Requests/LoginModel.cs new file mode 100644 index 00000000..d5bd6c61 --- /dev/null +++ b/src/Api/Controllers/Payload/Requests/LoginModel.cs @@ -0,0 +1,7 @@ +namespace Api.Controllers.Payload.Requests; + +public class LoginModel +{ + public string Email { get; set; } + public string Password { get; set; } +} \ No newline at end of file diff --git a/src/Api/Controllers/Payload/Requests/RefreshTokenRequest.cs b/src/Api/Controllers/Payload/Requests/RefreshTokenRequest.cs new file mode 100644 index 00000000..bcc3d8e2 --- /dev/null +++ b/src/Api/Controllers/Payload/Requests/RefreshTokenRequest.cs @@ -0,0 +1,7 @@ +namespace Api.Controllers.Payload.Requests; + +public class RefreshTokenRequest +{ + public string Token { get; set; } + public string RefreshToken { get; set; } +} \ No newline at end of file diff --git a/src/Api/Controllers/Payload/Responses/ApiAuthenticationResult.cs b/src/Api/Controllers/Payload/Responses/ApiAuthenticationResult.cs new file mode 100644 index 00000000..5cd8771f --- /dev/null +++ b/src/Api/Controllers/Payload/Responses/ApiAuthenticationResult.cs @@ -0,0 +1,7 @@ +namespace Api.Controllers.Payload.Responses; + +public class ApiAuthenticationResult +{ + public string Token { get; set; } + public string RefreshToken { get; set; } +} \ No newline at end of file diff --git a/src/Api/Controllers/RoomsController.cs b/src/Api/Controllers/RoomsController.cs index f5e9b44d..d7e4ecae 100644 --- a/src/Api/Controllers/RoomsController.cs +++ b/src/Api/Controllers/RoomsController.cs @@ -1,13 +1,16 @@ using Application.Common.Models; using Application.Common.Models.Dtos.Physical; +using Application.Identity; using Application.Rooms.Commands.CreateRoom; using Application.Rooms.Queries.GetEmptyContainersPaginated; +using Infrastructure.Identity.Authorization; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers; public class RoomsController : ApiControllerBase { + [RequiresRole(IdentityData.Roles.Admin)] [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] @@ -18,6 +21,7 @@ public async Task>> AddRoom(CreateRoomCommand comma return Ok(Result.Succeed(result)); } + [RequiresRole(IdentityData.Roles.Staff)] [HttpPost("empty-containers")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] diff --git a/src/Api/Controllers/StaffsController.cs b/src/Api/Controllers/StaffsController.cs index 1134884f..255335ef 100644 --- a/src/Api/Controllers/StaffsController.cs +++ b/src/Api/Controllers/StaffsController.cs @@ -1,12 +1,15 @@ using Application.Common.Models; +using Application.Identity; using Application.Staffs.Commands.CreateStaff; using Application.Users.Queries.Physical; +using Infrastructure.Identity.Authorization; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers; public class StaffsController : ApiControllerBase { + [RequiresRole(IdentityData.Roles.Admin)] [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] diff --git a/src/Api/Controllers/UsersController.cs b/src/Api/Controllers/UsersController.cs index e1e45086..33aa1714 100644 --- a/src/Api/Controllers/UsersController.cs +++ b/src/Api/Controllers/UsersController.cs @@ -1,8 +1,10 @@ using Application.Common.Models; +using Application.Identity; using Application.Users.Commands.CreateUser; using Application.Users.Commands.DisableUser; using Application.Users.Queries; using Application.Users.Queries.GetUsersByName; +using Infrastructure.Identity.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -23,10 +25,11 @@ public async Task>> CreateUser([FromBody] CreateUse } [Authorize] + [RequiresRole(IdentityData.Roles.Admin)] [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task>>> GetUsersByName(string? searchTerm, int page, int size) + public async Task>>> GetUsersByName(string? searchTerm, int? page, int? size) { var query = new GetUsersByNameQuery { @@ -39,6 +42,7 @@ public async Task>>> GetUsersByName(s } [HttpPost("disable")] + [RequiresRole(IdentityData.Roles.Admin)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/src/Api/Middlewares/ExceptionMiddleware.cs b/src/Api/Middlewares/ExceptionMiddleware.cs index dd065fc3..ad0c9107 100644 --- a/src/Api/Middlewares/ExceptionMiddleware.cs +++ b/src/Api/Middlewares/ExceptionMiddleware.cs @@ -1,3 +1,4 @@ +using System.Security.Authentication; using System.Text.Json; using Application.Common.Exceptions; using Application.Common.Models; @@ -31,6 +32,7 @@ public ExceptionMiddleware() { typeof(NotAllowedException), HandleNotAllowedException }, { typeof(RequestValidationException), HandleRequestValidationException }, { typeof(LimitExceededException) , HandleLimitExceededException }, + { typeof(AuthenticationException) , HandleAuthenticationException }, }; } @@ -87,6 +89,12 @@ private async void HandleLimitExceededException(HttpContext context, Exception e context.Response.StatusCode = StatusCodes.Status409Conflict; await WriteExceptionMessageAsync(context, ex); } + + private async void HandleAuthenticationException(HttpContext context, Exception ex) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await WriteExceptionMessageAsync(context, ex); + } private static async Task WriteExceptionMessageAsync(HttpContext context, Exception ex) { diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json index 58e7c997..59c84944 100644 --- a/src/Api/appsettings.Development.json +++ b/src/Api/appsettings.Development.json @@ -5,7 +5,9 @@ }, "JweSettings": { "SigningKeyId": "4bd28be8eac5414fb01c5cbe343b50144bd28be8eac5414fb01c5cbe343b50144bd28be8eac5414fb01c5cbe343b50144bd28be8eac5414fb01c5cbe343b50144bd28be8eac5414fb01c5cbe343b5014", - "EncryptionKeyId": "4bd28be8eac5414fb01c5cbe343b5014" + "EncryptionKeyId": "4bd28be8eac5414fb01c5cbe343b5014", + "TokenLifetime": "00:00:20", + "RefreshTokenLifetimeInDays": 3 }, "Serilog" : { "MinimumLevel" : { diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index ce87a574..ecc9e09f 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Application/Common/Interfaces/IIdentityService.cs b/src/Application/Common/Interfaces/IIdentityService.cs index 3368bb02..9efbd816 100644 --- a/src/Application/Common/Interfaces/IIdentityService.cs +++ b/src/Application/Common/Interfaces/IIdentityService.cs @@ -1,6 +1,9 @@ +using Application.Common.Models; + namespace Application.Common.Interfaces; public interface IIdentityService { - + Task RefreshTokenAsync(string token, string refreshToken); + Task LoginAsync(string email, string password); } \ No newline at end of file diff --git a/src/Application/Common/Models/AuthenticationResult.cs b/src/Application/Common/Models/AuthenticationResult.cs new file mode 100644 index 00000000..4eb91fca --- /dev/null +++ b/src/Application/Common/Models/AuthenticationResult.cs @@ -0,0 +1,12 @@ +using System.IdentityModel.Tokens.Jwt; +using Application.Common.Models.Dtos; +using Domain.Entities; +using Microsoft.IdentityModel.Tokens; + +namespace Application.Common.Models; + +public class AuthenticationResult +{ + public SecurityToken Token { get; set; } + public RefreshTokenDto RefreshToken { get; set; } +} \ No newline at end of file diff --git a/src/Application/Common/Models/Dtos/RefreshTokenDto.cs b/src/Application/Common/Models/Dtos/RefreshTokenDto.cs new file mode 100644 index 00000000..e0355b00 --- /dev/null +++ b/src/Application/Common/Models/Dtos/RefreshTokenDto.cs @@ -0,0 +1,27 @@ +using Application.Common.Mappings; +using AutoMapper; +using Domain.Entities; + +namespace Application.Common.Models.Dtos; + +public class RefreshTokenDto : IMapFrom +{ + public Guid Token { get; set; } + public string JwtId { get; set; } = null!; + public DateTime CreationDateTime { get; set; } + public DateTime ExpiryDateTime { get; set; } + public bool IsUsed { get; set; } + public bool IsInvalidated { get; set; } + public Guid UserId { get; set; } + + public void Mapping(Profile profile) + { + profile.CreateMap() + .ForMember(dest => dest.CreationDateTime, + opt => opt.MapFrom(src => src.CreationDateTime.ToDateTimeUnspecified())) + .ForMember(dest => dest.ExpiryDateTime, + opt => opt.MapFrom(src => src.ExpiryDateTime.ToDateTimeUnspecified())) + .ForMember(dest => dest.UserId, + opt => opt.MapFrom(src => src.User.Id)); + } +} \ No newline at end of file diff --git a/src/Application/Documents/Queries/GetDocumentById/GetDocumentByIdQuery.cs b/src/Application/Documents/Queries/GetDocumentById/GetDocumentByIdQuery.cs index b373014b..4bdc53c4 100644 --- a/src/Application/Documents/Queries/GetDocumentById/GetDocumentByIdQuery.cs +++ b/src/Application/Documents/Queries/GetDocumentById/GetDocumentByIdQuery.cs @@ -1,9 +1,12 @@ +using Application.Common.Interfaces; using Application.Common.Models.Dtos.Physical; +using AutoMapper; using MediatR; +using Microsoft.EntityFrameworkCore; namespace Application.Documents.Queries.GetDocumentById; public record GetDocumentByIdQuery : IRequest { - public Guid Id { get; set; } + public Guid Id { get; init; } } \ No newline at end of file diff --git a/src/Application/Users/Queries/GetUsersByName/GetUsersByNameQuery.cs b/src/Application/Users/Queries/GetUsersByName/GetUsersByNameQuery.cs index 19a26dfa..76604efb 100644 --- a/src/Application/Users/Queries/GetUsersByName/GetUsersByNameQuery.cs +++ b/src/Application/Users/Queries/GetUsersByName/GetUsersByNameQuery.cs @@ -10,8 +10,8 @@ namespace Application.Users.Queries.GetUsersByName; public record GetUsersByNameQuery : IRequest> { public string? SearchTerm { get; init; } - public int Page { get; init; } - public int Size { get; init; } + public int? Page { get; init; } + public int? Size { get; init; } } public class GetUsersByNameQueryHandler : IRequestHandler> @@ -26,13 +26,15 @@ public GetUsersByNameQueryHandler(IApplicationDbContext context, IMapper mapper) public async Task> Handle(GetUsersByNameQuery request, CancellationToken cancellationToken) { + var pageNumber = request.Page ?? 1; + var sizeNumber = request.Size ?? 5; var users = await _context.Users .Where(x => string.IsNullOrEmpty(request.SearchTerm) || x.FirstName.ToLower().Contains(request.SearchTerm.ToLower()) || x.LastName.ToLower().Contains(request.SearchTerm.ToLower())) .ProjectTo(_mapper.ConfigurationProvider) .OrderBy(x => x.Username) - .PaginatedListAsync(request.Page, request.Size); + .PaginatedListAsync(pageNumber, sizeNumber); return users; } } \ No newline at end of file diff --git a/src/Domain/Entities/RefreshToken.cs b/src/Domain/Entities/RefreshToken.cs new file mode 100644 index 00000000..7d922ba9 --- /dev/null +++ b/src/Domain/Entities/RefreshToken.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using NodaTime; + +namespace Domain.Entities; + +public class RefreshToken +{ + [Key] + public Guid Token { get; set; } + public string JwtId { get; set; } = null!; + public LocalDateTime CreationDateTime { get; set; } + public LocalDateTime ExpiryDateTime { get; set; } + public bool IsUsed { get; set; } + public bool IsInvalidated { get; set; } + public User User { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Infrastructure/ConfigureServices.cs b/src/Infrastructure/ConfigureServices.cs index 22854325..bd1bede7 100644 --- a/src/Infrastructure/ConfigureServices.cs +++ b/src/Infrastructure/ConfigureServices.cs @@ -2,9 +2,11 @@ using System.Security.Cryptography; using System.Text; using Application.Common.Interfaces; +using Infrastructure.Identity; using Infrastructure.Identity.Authentication; using Infrastructure.Persistence; using Infrastructure.Shared; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -19,6 +21,7 @@ public static IServiceCollection AddInfrastructureServices(this IServiceCollecti { services.AddApplicationDbContext(configuration); services.AddScoped(); + services.AddScoped(); services.AddJweAuthentication(configuration); @@ -50,16 +53,42 @@ private static IServiceCollection AddJweAuthentication(this IServiceCollection s services.Configure(options => { - options.EncryptionKeyId = jweSettings.EncryptionKeyId; + options.EncryptionKeyId = jweSettings!.EncryptionKeyId; options.SigningKeyId = jweSettings.SigningKeyId; + options.TokenLifetime = jweSettings.TokenLifetime; + options.RefreshTokenLifetimeInDays = jweSettings.RefreshTokenLifetimeInDays; }); - services.AddSingleton(_ => RSA.Create(3072)); - services.AddSingleton(_ => ECDsa.Create(ECCurve.NamedCurves.nistP256)); + var encryptionKey = RSA.Create(3072); + var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + + var privateEncryptionKey = new RsaSecurityKey(encryptionKey) {KeyId = jweSettings!.EncryptionKeyId}; + var publicSigningKey = new ECDsaSecurityKey(ECDsa.Create(signingKey.ExportParameters(false))) {KeyId = jweSettings.SigningKeyId}; + + var tokenValidationParameters = new TokenValidationParameters + { + ValidateAudience = false, + ValidateIssuer = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ClockSkew = TimeSpan.Zero, + // public key for signing + IssuerSigningKey = publicSigningKey, + + // private key for encryption + TokenDecryptionKey = privateEncryptionKey, + }; + services.AddSingleton(encryptionKey); + services.AddSingleton(signingKey); + services.AddSingleton(tokenValidationParameters); + services.AddAuthentication(JweAuthenticationOptions.DefaultScheme) .AddScheme(JweAuthenticationOptions.DefaultScheme, - _ => { }); + options => + { + options.TokenValidationParameters = tokenValidationParameters; + }); return services; } diff --git a/src/Infrastructure/Identity/Authentication/JweAuthenticationHandler.cs b/src/Infrastructure/Identity/Authentication/JweAuthenticationHandler.cs index fc42f445..587a9fed 100644 --- a/src/Infrastructure/Identity/Authentication/JweAuthenticationHandler.cs +++ b/src/Infrastructure/Identity/Authentication/JweAuthenticationHandler.cs @@ -1,8 +1,11 @@ +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; using System.Text.Encodings.Web; using Infrastructure.Shared; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; @@ -12,57 +15,48 @@ namespace Infrastructure.Identity.Authentication; public class JweAuthenticationHandler : AuthenticationHandler { - private readonly JweSettings _jweSettings; - - private readonly RSA _encryptionKey; - private readonly ECDsa _signingKey; public JweAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, - ISystemClock clock, IOptions jweSettings, - RSA encryptionKey, ECDsa signingKey) : base(options, logger, encoder, clock) + ISystemClock clock) : base(options, logger, encoder, clock) { - _encryptionKey = encryptionKey; - _signingKey = signingKey; - _jweSettings = jweSettings.Value; } protected override async Task HandleAuthenticateAsync() { //check header first - if (!Request.Headers.ContainsKey(Options.TokenHeaderName)) + if (!Request.Cookies.ContainsKey(JweAuthenticationOptions.TokenCookieName)) { - return AuthenticateResult.Fail($"Missing header: {Options.TokenHeaderName}"); + return AuthenticateResult.Fail($"Missing cookie: {JweAuthenticationOptions.TokenCookieName}"); } //get the header and validate - string token = Request.Headers[Options.TokenHeaderName]!; - token = token.Substring(token.IndexOf(" ", StringComparison.Ordinal) + 1); - - var privateEncryptionKey = new RsaSecurityKey(_encryptionKey) {KeyId = _jweSettings.EncryptionKeyId}; - var publicSigningKey = new ECDsaSecurityKey(ECDsa.Create(_signingKey.ExportParameters(false))) {KeyId = _jweSettings.SigningKeyId}; + var token = Request.Cookies[JweAuthenticationOptions.TokenCookieName]!; - var handler = new JsonWebTokenHandler(); - var result = handler.ValidateToken(token, - new TokenValidationParameters - { - ValidateAudience = false, - ValidateIssuer = false, - ValidateLifetime = true, - // public key for signing - IssuerSigningKey = publicSigningKey, - - // private key for encryption - TokenDecryptionKey = privateEncryptionKey - }); + var handler = new JwtSecurityTokenHandler(); - if (!result.IsValid) + try { - return AuthenticateResult.Fail("Invalid token."); + var claimsPrincipal = handler.ValidateToken(token, + Options.TokenValidationParameters, out var validatedToken); + + Context.User = claimsPrincipal; + + return validatedToken is null + ? 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); } - - var claimsPrincipal = new ClaimsPrincipal(result.ClaimsIdentity); - - return AuthenticateResult.Success(new AuthenticationTicket(claimsPrincipal, this.Scheme.Name)); } } \ No newline at end of file diff --git a/src/Infrastructure/Identity/Authentication/JweAuthenticationOptions.cs b/src/Infrastructure/Identity/Authentication/JweAuthenticationOptions.cs index a01c0b72..868e2511 100644 --- a/src/Infrastructure/Identity/Authentication/JweAuthenticationOptions.cs +++ b/src/Infrastructure/Identity/Authentication/JweAuthenticationOptions.cs @@ -1,9 +1,11 @@ using Microsoft.AspNetCore.Authentication; +using Microsoft.IdentityModel.Tokens; namespace Infrastructure.Identity.Authentication; public class JweAuthenticationOptions : AuthenticationSchemeOptions { - public const string DefaultScheme = "Bearer"; - public string TokenHeaderName { get; set; } = "Authorization"; + public const string DefaultScheme = "Cookies"; + public const string TokenCookieName = "JweToken"; + public TokenValidationParameters TokenValidationParameters { get; set; } = new(); } \ No newline at end of file diff --git a/src/Infrastructure/Identity/Authorization/RequiresClaimAttribute.cs b/src/Infrastructure/Identity/Authorization/RequiresClaimAttribute.cs deleted file mode 100644 index a1fdacb7..00000000 --- a/src/Infrastructure/Identity/Authorization/RequiresClaimAttribute.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace Infrastructure.Identity.Authorization; - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] -public class RequiresClaimAttribute : Attribute, IAuthorizationFilter -{ - private readonly string _claimName; - private readonly string _claimValue; - - public RequiresClaimAttribute(string claimName, string claimValue) - { - _claimName = claimName; - _claimValue = claimValue; - } - - public void OnAuthorization(AuthorizationFilterContext context) - { - if (context.HttpContext.User.HasClaim(_claimName, _claimValue)) - { - context.Result = new ForbidResult(); - } - } -} \ No newline at end of file diff --git a/src/Infrastructure/Identity/Authorization/RequiresRoleAttribute.cs b/src/Infrastructure/Identity/Authorization/RequiresRoleAttribute.cs new file mode 100644 index 00000000..863a36d7 --- /dev/null +++ b/src/Infrastructure/Identity/Authorization/RequiresRoleAttribute.cs @@ -0,0 +1,38 @@ +using Application.Identity; +using Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; + +namespace Infrastructure.Identity.Authorization; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class RequiresRoleAttribute : Attribute, IAuthorizationFilter +{ + private readonly string[] _claimValues; + + public RequiresRoleAttribute(params string[] claimValues) + { + _claimValues = claimValues; + } + + public void OnAuthorization(AuthorizationFilterContext context) + { + var dbContext = context.HttpContext.RequestServices.GetRequiredService(); + + const string emailClaim = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"; + var email = context.HttpContext.User.Claims.SingleOrDefault(y => y.Type.Equals(emailClaim))!.Value; + + var user = dbContext.Users.FirstOrDefault(x => x.Email!.Equals(email)); + + foreach (var claimValue in _claimValues) + { + if (user!.Role.Equals(claimValue)) + { + return; + } + } + + context.Result = new ForbidResult(); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Identity/IdentityService.cs b/src/Infrastructure/Identity/IdentityService.cs new file mode 100644 index 00000000..ea3b4c67 --- /dev/null +++ b/src/Infrastructure/Identity/IdentityService.cs @@ -0,0 +1,198 @@ +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Authentication; +using System.Security.Claims; +using System.Security.Cryptography; +using Application.Common.Interfaces; +using Application.Common.Models; +using Application.Common.Models.Dtos; +using Application.Helpers; +using Application.Identity; +using AutoMapper; +using Domain.Entities; +using Infrastructure.Persistence; +using Infrastructure.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using NodaTime; +using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; + +namespace Infrastructure.Identity; + +public class IdentityService : IIdentityService +{ + private readonly TokenValidationParameters _tokenValidationParameters; + private readonly JweSettings _jweSettings; + private readonly ApplicationDbContext _context; + private readonly RSA _encryptionKey; + private readonly ECDsa _signingKey; + private readonly IMapper _mapper; + + public IdentityService(TokenValidationParameters tokenValidationParameters, IOptions jweSettingsOptions, ApplicationDbContext context, RSA encryptionKey, ECDsa signingKey, IMapper mapper) + { + _tokenValidationParameters = tokenValidationParameters; + _jweSettings = jweSettingsOptions.Value; + _context = context; + _encryptionKey = encryptionKey; + _signingKey = signingKey; + _mapper = mapper; + } + + public async Task RefreshTokenAsync(string token, string refreshToken) + { + var validatedToken = GetPrincipalFromToken(token); + + if (validatedToken is null) + { + throw new AuthenticationException("Invalid token."); + } + + 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) + { + throw new AuthenticationException("Invalid token."); + } + + 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) + { + throw new AuthenticationException("This token has not expired yet."); + } + + 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) + { + throw new AuthenticationException("This refresh token does not exist."); + } + + if (DateTime.UtcNow > storedRefreshToken.ExpiryDateTime.ToDateTimeUnspecified().ToUniversalTime()) + { + throw new AuthenticationException("This refresh token has expired."); + } + + if (storedRefreshToken.IsInvalidated) + { + throw new AuthenticationException("This refresh token has been invalidated."); + } + + if (storedRefreshToken.IsUsed) + { + throw new AuthenticationException("This refresh token has been used."); + } + + if (!storedRefreshToken.JwtId.Equals(jti)) + { + throw new AuthenticationException("This refresh token does not match this Jwt."); + } + + storedRefreshToken.IsUsed = true; + _context.RefreshTokens.Update(storedRefreshToken); + await _context.SaveChangesAsync(); + + return await GenerateAuthenticationResultForUserAsync(user); + } + + private ClaimsPrincipal? GetPrincipalFromToken(string token) + { + var handler = new JwtSecurityTokenHandler(); + try + { + var newTokenValidationParameters = new TokenValidationParameters() + { + ValidateAudience = _tokenValidationParameters.ValidateAudience, + ValidateIssuer = _tokenValidationParameters.ValidateIssuer, + ValidateLifetime = false, + ValidateIssuerSigningKey = _tokenValidationParameters.ValidateIssuerSigningKey, + ClockSkew = TimeSpan.Zero, + // public key for signing + IssuerSigningKey = _tokenValidationParameters.IssuerSigningKey, + + // private key for encryption + TokenDecryptionKey = _tokenValidationParameters.TokenDecryptionKey, + }; + var principal = handler.ValidateToken(token, newTokenValidationParameters, out _); + + return principal; + } + catch (SecurityTokenExpiredException ex) + { + return null; + } + catch (Exception exception) + { + Console.WriteLine(exception.StackTrace); + return null; + } + } + + public async Task LoginAsync(string email, string password) + { + var user = _context.Users.FirstOrDefault(x => x.Email!.Equals(email)); + + if (user is null || !user.PasswordHash.Equals(SecurityUtil.Hash(password))) + { + throw new AuthenticationException("Username or password is invalid."); + } + + return await GenerateAuthenticationResultForUserAsync(user); + } + + private async Task GenerateAuthenticationResultForUserAsync(User user) + { + var utcNow = DateTime.UtcNow; + var authClaims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Username), + new(JwtRegisteredClaimNames.Email, user.Email!), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.Iat, utcNow.ToString(CultureInfo.InvariantCulture)), + }; + var publicEncryptionKey = new RsaSecurityKey(_encryptionKey.ExportParameters(false)) {KeyId = _jweSettings.EncryptionKeyId}; + var privateSigningKey = new ECDsaSecurityKey(_signingKey) {KeyId = _jweSettings.SigningKeyId}; + + var tokenDescriptor = new SecurityTokenDescriptor() + { + Subject = new ClaimsIdentity(authClaims), + SigningCredentials = + new SigningCredentials(privateSigningKey, SecurityAlgorithms.EcdsaSha256), + EncryptingCredentials = + new EncryptingCredentials(publicEncryptionKey, SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512), + Expires = utcNow.Add(_jweSettings.TokenLifetime), + }; + + var handler = new JwtSecurityTokenHandler(); + + var token = handler.CreateToken(tokenDescriptor); + + var refreshToken = new RefreshToken() + { + JwtId = token.Id, + User = user, + CreationDateTime = LocalDateTime.FromDateTime(utcNow), + ExpiryDateTime = LocalDateTime.FromDateTime(utcNow.AddDays(_jweSettings.RefreshTokenLifetimeInDays)) + }; + + await _context.RefreshTokens.AddAsync(refreshToken); + await _context.SaveChangesAsync(); + return new() + { + Token = token, + RefreshToken = _mapper.Map(refreshToken) + }; + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/ApplicationDbContext.cs b/src/Infrastructure/Persistence/ApplicationDbContext.cs index 75aea0ec..dbae579c 100644 --- a/src/Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/Infrastructure/Persistence/ApplicationDbContext.cs @@ -27,6 +27,8 @@ public ApplicationDbContext( public DbSet Documents => Set(); public DbSet Borrows => Set(); + public DbSet RefreshTokens => Set(); + protected override void OnModelCreating(ModelBuilder builder) { // Scan for entity configurations using FluentAPI diff --git a/src/Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs b/src/Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 00000000..a8bb4eb2 --- /dev/null +++ b/src/Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,37 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Persistence.Configurations; + +public class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Token); + builder.Property(x => x.Token) + .ValueGeneratedOnAdd(); + + builder.Property(x => x.JwtId) + .IsRequired(); + + builder.Property(x => x.CreationDateTime) + .IsRequired(); + + builder.Property(x => x.ExpiryDateTime) + .IsRequired(); + + builder.Property(x => x.IsUsed) + .IsRequired() + .HasDefaultValue(false); + + builder.Property(x => x.IsInvalidated) + .IsRequired() + .HasDefaultValue(false); + + builder.HasOne(x => x.User) + .WithMany() + .HasForeignKey("UserId") + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index c16d7921..a69d09d2 100644 --- a/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -38,7 +38,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasAlternateKey("Name"); - b.ToTable("Departments", (string)null); + b.ToTable("Departments"); }); modelBuilder.Entity("Domain.Entities.Physical.Borrow", b => @@ -164,7 +164,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Name") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(64) + .HasColumnType("character varying(64)"); b.Property("NumberOfFolders") .HasColumnType("integer"); @@ -207,7 +208,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasAlternateKey("Name"); - b.ToTable("Rooms", (string)null); + b.ToTable("Rooms"); }); modelBuilder.Entity("Domain.Entities.Physical.Staff", b => @@ -225,7 +226,43 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("RoomId") .IsUnique(); - b.ToTable("Staffs", (string)null); + b.ToTable("Staffs"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.Property("Token") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExpiryDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("IsInvalidated") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("JwtId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); }); modelBuilder.Entity("Domain.Entities.User", b => @@ -290,7 +327,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("DepartmentId"); - b.ToTable("Users", (string)null); + b.ToTable("Users"); }); modelBuilder.Entity("Domain.Entities.Physical.Borrow", b => @@ -374,6 +411,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Domain.Entities.User", b => { b.HasOne("Domain.Entities.Department", "Department") diff --git a/src/Infrastructure/Shared/JweSettings.cs b/src/Infrastructure/Shared/JweSettings.cs index 24f1f0f0..3fe70a4b 100644 --- a/src/Infrastructure/Shared/JweSettings.cs +++ b/src/Infrastructure/Shared/JweSettings.cs @@ -4,4 +4,6 @@ public class JweSettings { public string SigningKeyId { get; set; } public string EncryptionKeyId { get; set; } + public TimeSpan TokenLifetime { get; set; } + public int RefreshTokenLifetimeInDays { get; set; } } \ No newline at end of file diff --git a/tests/Application.Tests.Unit/Common/Mappings/MappingTests.cs b/tests/Application.Tests.Unit/Common/Mappings/MappingTests.cs index d9bd7e9a..36b57359 100644 --- a/tests/Application.Tests.Unit/Common/Mappings/MappingTests.cs +++ b/tests/Application.Tests.Unit/Common/Mappings/MappingTests.cs @@ -1,5 +1,6 @@ using System.Runtime.Serialization; using Application.Common.Mappings; +using Application.Common.Models.Dtos; using Application.Common.Models.Dtos.Physical; using Application.Documents.Queries.GetAllDocumentsPaginated; using Application.Rooms.Queries.GetEmptyContainersPaginated; @@ -43,6 +44,7 @@ public void ShouldHaveValidConfiguration() [InlineData(typeof(Document), typeof(DocumentDto))] [InlineData(typeof(Document), typeof(DocumentItemDto))] [InlineData(typeof(Borrow), typeof(BorrowDto))] + [InlineData(typeof(RefreshToken), typeof(RefreshTokenDto))] public void ShouldSupportMappingFromSourceToDestination(Type source, Type destination) { // Arrange From 84fbc734696263330c4c8d98f20d36658368c782 Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Thu, 25 May 2023 16:01:58 +0700 Subject: [PATCH 02/10] feat: log out --- src/Api/Controllers/AuthController.cs | 58 ++++++++++++++++--- .../Responses/ApiAuthenticationResult.cs | 7 --- .../Payload/Responses/LoginResult.cs | 15 +++++ src/Api/appsettings.Development.json | 2 +- .../Common/Interfaces/IIdentityService.cs | 4 +- .../ImportDocument/ImportDocumentCommand.cs | 2 + .../Identity/IdentityService.cs | 35 +++++++++-- 7 files changed, 100 insertions(+), 23 deletions(-) delete mode 100644 src/Api/Controllers/Payload/Responses/ApiAuthenticationResult.cs create mode 100644 src/Api/Controllers/Payload/Responses/LoginResult.cs diff --git a/src/Api/Controllers/AuthController.cs b/src/Api/Controllers/AuthController.cs index 40af8544..5971cb2f 100644 --- a/src/Api/Controllers/AuthController.cs +++ b/src/Api/Controllers/AuthController.cs @@ -1,12 +1,10 @@ using System.IdentityModel.Tokens.Jwt; using Api.Controllers.Payload.Requests; +using Api.Controllers.Payload.Responses; using Application.Common.Interfaces; using Application.Common.Models; using Application.Common.Models.Dtos; using Domain.Entities; -using Application.Helpers; -using Application.Identity; -using Infrastructure.Shared; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; @@ -28,20 +26,42 @@ public AuthController(IIdentityService identityService) [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task Login([FromBody] LoginModel loginModel) + public async Task>> Login([FromBody] LoginModel loginModel) { - var authResult = await _identityService.LoginAsync(loginModel.Email, loginModel.Password); + var result = await _identityService.LoginAsync(loginModel.Email, loginModel.Password); - SetRefreshToken(authResult.RefreshToken); - SetJweToken(authResult.Token); + SetRefreshToken(result.AuthResult.RefreshToken); + SetJweToken(result.AuthResult.Token); + + var loginResult = new LoginResult() + { + Id = result.UserCredentials.Id, + Username = result.UserCredentials.Username, + Email = result.UserCredentials.Email, + Department = result.UserCredentials.Department, + Position = result.UserCredentials.Position, + Role = result.UserCredentials.Role, + FirstName = result.UserCredentials.FirstName, + LastName = result.UserCredentials.LastName, + }; - return Ok(); + return Ok(Result.Succeed(loginResult)); } [Authorize] [HttpPost] - public ActionResult>> Logout() + public async Task Logout() { + var refreshToken = Request.Cookies[nameof(RefreshToken)]; + var jweToken = Request.Cookies["JweToken"]; + + var loggedOut = await _identityService.LogoutAsync(jweToken!, refreshToken!); + + if (!loggedOut) return Ok(); + + RemoveJweToken(jweToken); + RemoveRefreshToken(refreshToken); + return Ok(); } @@ -79,4 +99,24 @@ private void SetRefreshToken(RefreshTokenDto newRefreshToken) }; Response.Cookies.Append(nameof(RefreshToken), newRefreshToken.Token.ToString(), cookieOptions); } + + private void RemoveJweToken(string jweToken) + { + var cookieOptions = new CookieOptions + { + HttpOnly = true, + Expires = DateTimeOffset.FromUnixTimeSeconds(0) + }; + Response.Cookies.Append("JweToken", jweToken, cookieOptions); + } + + private void RemoveRefreshToken(string newRefreshToken) + { + var cookieOptions = new CookieOptions + { + HttpOnly = true, + Expires = DateTimeOffset.FromUnixTimeSeconds(0) + }; + Response.Cookies.Append(nameof(RefreshToken), newRefreshToken, cookieOptions); + } } \ No newline at end of file diff --git a/src/Api/Controllers/Payload/Responses/ApiAuthenticationResult.cs b/src/Api/Controllers/Payload/Responses/ApiAuthenticationResult.cs deleted file mode 100644 index 5cd8771f..00000000 --- a/src/Api/Controllers/Payload/Responses/ApiAuthenticationResult.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Api.Controllers.Payload.Responses; - -public class ApiAuthenticationResult -{ - public string Token { get; set; } - public string RefreshToken { get; set; } -} \ No newline at end of file diff --git a/src/Api/Controllers/Payload/Responses/LoginResult.cs b/src/Api/Controllers/Payload/Responses/LoginResult.cs new file mode 100644 index 00000000..29fb21c4 --- /dev/null +++ b/src/Api/Controllers/Payload/Responses/LoginResult.cs @@ -0,0 +1,15 @@ +using Application.Users.Queries; + +namespace Api.Controllers.Payload.Responses; + +public class LoginResult +{ + public Guid Id { get; set; } + public string Username { get; set; } + public string Email { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public DepartmentDto Department { get; set; } + public string Role { get; set; } + public string Position { get; set; } +} \ No newline at end of file diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json index 59c84944..70a1ba99 100644 --- a/src/Api/appsettings.Development.json +++ b/src/Api/appsettings.Development.json @@ -6,7 +6,7 @@ "JweSettings": { "SigningKeyId": "4bd28be8eac5414fb01c5cbe343b50144bd28be8eac5414fb01c5cbe343b50144bd28be8eac5414fb01c5cbe343b50144bd28be8eac5414fb01c5cbe343b50144bd28be8eac5414fb01c5cbe343b5014", "EncryptionKeyId": "4bd28be8eac5414fb01c5cbe343b5014", - "TokenLifetime": "00:00:20", + "TokenLifetime": "00:20:00", "RefreshTokenLifetimeInDays": 3 }, "Serilog" : { diff --git a/src/Application/Common/Interfaces/IIdentityService.cs b/src/Application/Common/Interfaces/IIdentityService.cs index 9efbd816..e5973425 100644 --- a/src/Application/Common/Interfaces/IIdentityService.cs +++ b/src/Application/Common/Interfaces/IIdentityService.cs @@ -1,9 +1,11 @@ using Application.Common.Models; +using Application.Users.Queries; namespace Application.Common.Interfaces; public interface IIdentityService { Task RefreshTokenAsync(string token, string refreshToken); - Task LoginAsync(string email, string password); + Task<(AuthenticationResult AuthResult, UserDto UserCredentials)> LoginAsync(string email, string password); + Task LogoutAsync(string token, string refreshToken); } \ No newline at end of file diff --git a/src/Application/Documents/Commands/ImportDocument/ImportDocumentCommand.cs b/src/Application/Documents/Commands/ImportDocument/ImportDocumentCommand.cs index dff39b09..373e7202 100644 --- a/src/Application/Documents/Commands/ImportDocument/ImportDocumentCommand.cs +++ b/src/Application/Documents/Commands/ImportDocument/ImportDocumentCommand.cs @@ -65,6 +65,8 @@ public async Task Handle(ImportDocumentCommand request, Cancellatio }; var result = await _context.Documents.AddAsync(entity, cancellationToken); + folder.NumberOfDocuments += 1; + _context.Folders.Update(folder); await _context.SaveChangesAsync(cancellationToken); return _mapper.Map(result.Entity); } diff --git a/src/Infrastructure/Identity/IdentityService.cs b/src/Infrastructure/Identity/IdentityService.cs index ea3b4c67..3ce80018 100644 --- a/src/Infrastructure/Identity/IdentityService.cs +++ b/src/Infrastructure/Identity/IdentityService.cs @@ -7,7 +7,7 @@ using Application.Common.Models; using Application.Common.Models.Dtos; using Application.Helpers; -using Application.Identity; +using Application.Users.Queries; using AutoMapper; using Domain.Entities; using Infrastructure.Persistence; @@ -100,8 +100,7 @@ public async Task RefreshTokenAsync(string token, string r throw new AuthenticationException("This refresh token does not match this Jwt."); } - storedRefreshToken.IsUsed = true; - _context.RefreshTokens.Update(storedRefreshToken); + _context.RefreshTokens.Remove(storedRefreshToken); await _context.SaveChangesAsync(); return await GenerateAuthenticationResultForUserAsync(user); @@ -140,7 +139,7 @@ public async Task RefreshTokenAsync(string token, string r } } - public async Task LoginAsync(string email, string password) + public async Task<(AuthenticationResult, UserDto)> LoginAsync(string email, string password) { var user = _context.Users.FirstOrDefault(x => x.Email!.Equals(email)); @@ -149,7 +148,33 @@ public async Task LoginAsync(string email, string password throw new AuthenticationException("Username or password is invalid."); } - return await GenerateAuthenticationResultForUserAsync(user); + return (await GenerateAuthenticationResultForUserAsync(user), _mapper.Map(user)); + } + + public async Task LogoutAsync(string token, string refreshToken) + { + var validatedToken = GetPrincipalFromToken(token); + + if (validatedToken is null) + { + throw new AuthenticationException("Invalid token."); + } + + 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 true; + + if (!storedRefreshToken!.JwtId.Equals(jti)) + { + throw new AuthenticationException("This refresh token does not match this Jwt."); + } + + _context.RefreshTokens.Remove(storedRefreshToken); + await _context.SaveChangesAsync(); + + return true; } private async Task GenerateAuthenticationResultForUserAsync(User user) From 7b99de41a745b8e5556d732ae21bc5beeab877de Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Thu, 25 May 2023 16:39:10 +0700 Subject: [PATCH 03/10] add: data seed --- src/Api/Program.cs | 2 +- .../Persistence/ApplicationDbContextSeed.cs | 62 ++++++++++++++----- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/Api/Program.cs b/src/Api/Program.cs index 42a37f8f..673d4cd1 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -25,7 +25,7 @@ app.MigrateDatabase((context, _) => { - ApplicationDbContextSeed.Seed(context, builder.Configuration, Log.Logger).Wait(); + ApplicationDbContextSeed.Seed(context, Log.Logger).Wait(); }) .Run(); } diff --git a/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs b/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs index 15ec99c7..61614b3a 100644 --- a/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs +++ b/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs @@ -1,16 +1,21 @@ +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; public class ApplicationDbContextSeed { - public static async Task Seed(ApplicationDbContext context, IConfiguration configuration, ILogger logger) + public static async Task Seed(ApplicationDbContext context, ILogger logger) { 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 +24,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 From c21ba97a9d07cd12e1ca4f4ed43f722ec91be654 Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Thu, 25 May 2023 17:00:56 +0700 Subject: [PATCH 04/10] add: validate endpoint --- src/Api/Controllers/AuthController.cs | 17 ++++++ .../Common/Interfaces/IIdentityService.cs | 1 + .../Identity/IdentityService.cs | 59 +++++++++++++++++++ .../BaseClassFixture.cs | 2 +- .../Queries/GetAllDepartmentsTests.cs | 5 +- 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/Api/Controllers/AuthController.cs b/src/Api/Controllers/AuthController.cs index 5971cb2f..77444950 100644 --- a/src/Api/Controllers/AuthController.cs +++ b/src/Api/Controllers/AuthController.cs @@ -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 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/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/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 From ddb148a8a804a7f68fdf9c3ed93e8c70e68aa8ba Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Thu, 25 May 2023 23:58:19 +0700 Subject: [PATCH 05/10] refactor: change implementation to removing cookies --- src/Api/Controllers/AuthController.cs | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/Api/Controllers/AuthController.cs b/src/Api/Controllers/AuthController.cs index 77444950..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(); } @@ -117,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 From fad3a3ad0ad393f720c4ab2cdacddbbbbefbae96 Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Fri, 26 May 2023 00:09:33 +0700 Subject: [PATCH 06/10] fix: disable command not handle all cases and refactor something --- .../Users/Commands/DisableUser/DisableUserCommand.cs | 9 +++++++-- .../Identity/Authentication/JweAuthenticationHandler.cs | 8 -------- 2 files changed, 7 insertions(+), 10 deletions(-) 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); From 8ac3e4fa089abc31c502f10e662e5cc79b886bd0 Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Fri, 26 May 2023 00:14:17 +0700 Subject: [PATCH 07/10] add: seed env --- src/Api/Program.cs | 2 +- src/Api/appsettings.Development.json | 1 + src/Infrastructure/Persistence/ApplicationDbContextSeed.cs | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Api/Program.cs b/src/Api/Program.cs index 673d4cd1..42a37f8f 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -25,7 +25,7 @@ app.MigrateDatabase((context, _) => { - ApplicationDbContextSeed.Seed(context, Log.Logger).Wait(); + ApplicationDbContextSeed.Seed(context, builder.Configuration, Log.Logger).Wait(); }) .Run(); } 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/Infrastructure/Persistence/ApplicationDbContextSeed.cs b/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs index 61614b3a..a6593d43 100644 --- a/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs +++ b/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs @@ -11,8 +11,10 @@ namespace Infrastructure.Persistence; public class ApplicationDbContextSeed { - public static async Task Seed(ApplicationDbContext context, ILogger logger) + public static async Task Seed(ApplicationDbContext context, IConfiguration configuration, ILogger logger) { + if (!configuration.GetValue("Seed")) return; + try { await TrySeedAsync(context); From b6298bd003bd3c52d6e0d0af648524181e1fa92b Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Fri, 26 May 2023 12:04:46 +0700 Subject: [PATCH 08/10] add: allow credentials --- src/Api/ConfigureServices.cs | 1 + 1 file changed, 1 insertion(+) 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(); }); }); From fa1a97148b9b12456c1db82c6d8ff5adcb6f9a8f Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Fri, 26 May 2023 12:07:18 +0700 Subject: [PATCH 09/10] update: origin localhost 3000 on dev --- src/Api/ConfigureServices.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api/ConfigureServices.cs b/src/Api/ConfigureServices.cs index 0fd0aa2b..6dea0c7d 100644 --- a/src/Api/ConfigureServices.cs +++ b/src/Api/ConfigureServices.cs @@ -20,7 +20,7 @@ public static IServiceCollection AddApiServices(this IServiceCollection services { options.AddPolicy("AllowAllOrigins", builder => { - builder.AllowAnyOrigin(); + builder.WithOrigins("http://localhost:3000"); builder.AllowAnyHeader(); builder.AllowAnyMethod(); builder.AllowCredentials(); From a27c6692be07db2d0ce44d9722e6fd1d39032876 Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Fri, 26 May 2023 12:14:15 +0700 Subject: [PATCH 10/10] fix: revert to any origin --- src/Api/ConfigureServices.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api/ConfigureServices.cs b/src/Api/ConfigureServices.cs index 6dea0c7d..0fd0aa2b 100644 --- a/src/Api/ConfigureServices.cs +++ b/src/Api/ConfigureServices.cs @@ -20,7 +20,7 @@ public static IServiceCollection AddApiServices(this IServiceCollection services { options.AddPolicy("AllowAllOrigins", builder => { - builder.WithOrigins("http://localhost:3000"); + builder.AllowAnyOrigin(); builder.AllowAnyHeader(); builder.AllowAnyMethod(); builder.AllowCredentials();