diff --git a/eShopOnWeb.sln b/eShopOnWeb.sln index 248c0ab..acc9f8b 100755 --- a/eShopOnWeb.sln +++ b/eShopOnWeb.sln @@ -30,12 +30,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{1FCBE191-34FE-4B2E-8915-CA81553958AD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PublicApi", "src\PublicApi\PublicApi.csproj", "{B5E4F33C-4667-4A55-AF6A-740F84C4CF3A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {227CF035-29B0-448D-97E4-944F9EA850E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {227CF035-29B0-448D-97E4-944F9EA850E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {227CF035-29B0-448D-97E4-944F9EA850E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {227CF035-29B0-448D-97E4-944F9EA850E5}.Release|Any CPU.Build.0 = Release|Any CPU {7C461394-ABDC-43CD-A798-71249C58BA67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7C461394-ABDC-43CD-A798-71249C58BA67}.Debug|Any CPU.Build.0 = Debug|Any CPU {7C461394-ABDC-43CD-A798-71249C58BA67}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -56,25 +62,26 @@ Global {7EFB5482-F942-4C3D-94B0-9B70596E6D0A}.Debug|Any CPU.Build.0 = Debug|Any CPU {7EFB5482-F942-4C3D-94B0-9B70596E6D0A}.Release|Any CPU.ActiveCfg = Release|Any CPU {7EFB5482-F942-4C3D-94B0-9B70596E6D0A}.Release|Any CPU.Build.0 = Release|Any CPU - {227CF035-29B0-448D-97E4-944F9EA850E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {227CF035-29B0-448D-97E4-944F9EA850E5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {227CF035-29B0-448D-97E4-944F9EA850E5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {227CF035-29B0-448D-97E4-944F9EA850E5}.Release|Any CPU.Build.0 = Release|Any CPU {1FCBE191-34FE-4B2E-8915-CA81553958AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1FCBE191-34FE-4B2E-8915-CA81553958AD}.Debug|Any CPU.Build.0 = Debug|Any CPU {1FCBE191-34FE-4B2E-8915-CA81553958AD}.Release|Any CPU.ActiveCfg = Release|Any CPU {1FCBE191-34FE-4B2E-8915-CA81553958AD}.Release|Any CPU.Build.0 = Release|Any CPU + {B5E4F33C-4667-4A55-AF6A-740F84C4CF3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5E4F33C-4667-4A55-AF6A-740F84C4CF3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5E4F33C-4667-4A55-AF6A-740F84C4CF3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5E4F33C-4667-4A55-AF6A-740F84C4CF3A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {227CF035-29B0-448D-97E4-944F9EA850E5} = {419A6ACE-0419-4315-A6FB-B0E63D39432E} {7C461394-ABDC-43CD-A798-71249C58BA67} = {419A6ACE-0419-4315-A6FB-B0E63D39432E} {7FED7440-2311-4D1E-958B-3E887C585CD2} = {419A6ACE-0419-4315-A6FB-B0E63D39432E} {EF6877E6-59CB-43A7-8C2C-E70DD70CC5B6} = {15EA4737-125B-4E6E-A806-E13B7EBCDCCF} {0F576306-7E2D-49B7-87B1-EB5D94CFD5FC} = {15EA4737-125B-4E6E-A806-E13B7EBCDCCF} {7EFB5482-F942-4C3D-94B0-9B70596E6D0A} = {15EA4737-125B-4E6E-A806-E13B7EBCDCCF} - {227CF035-29B0-448D-97E4-944F9EA850E5} = {419A6ACE-0419-4315-A6FB-B0E63D39432E} + {B5E4F33C-4667-4A55-AF6A-740F84C4CF3A} = {419A6ACE-0419-4315-A6FB-B0E63D39432E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {49813262-5DA3-4D61-ABD3-493C74CE8C2B} diff --git a/src/ApplicationCore/Constants/AuthorizationConstants.cs b/src/ApplicationCore/Constants/AuthorizationConstants.cs index 7ec183b..c2d14c8 100644 --- a/src/ApplicationCore/Constants/AuthorizationConstants.cs +++ b/src/ApplicationCore/Constants/AuthorizationConstants.cs @@ -7,6 +7,10 @@ public const string ADMINISTRATORS = "Administrators"; } + // TODO: Don't use this in production public const string DEFAULT_PASSWORD = "Pass@word1"; + + // TODO: Change this to an environment variable + public const string JWT_SECRET_KEY = "SecretKeyOfDoomThatMustBeAMinimumNumberOfBytes"; } } diff --git a/src/ApplicationCore/Interfaces/ITokenClaimsService.cs b/src/ApplicationCore/Interfaces/ITokenClaimsService.cs new file mode 100644 index 0000000..a378c1c --- /dev/null +++ b/src/ApplicationCore/Interfaces/ITokenClaimsService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.ApplicationCore.Interfaces +{ + public interface ITokenClaimsService + { + Task GetTokenAsync(string userName); + } +} diff --git a/src/Infrastructure/Identity/IdentityTokenClaimService.cs b/src/Infrastructure/Identity/IdentityTokenClaimService.cs new file mode 100644 index 0000000..f251803 --- /dev/null +++ b/src/Infrastructure/Identity/IdentityTokenClaimService.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.eShopWeb.ApplicationCore.Constants; +using Microsoft.eShopWeb.ApplicationCore.Interfaces; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.Infrastructure.Identity +{ + public class IdentityTokenClaimService : ITokenClaimsService + { + private readonly UserManager _userManager; + + public IdentityTokenClaimService(UserManager userManager) + { + _userManager = userManager; + } + + public async Task GetTokenAsync(string userName) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.ASCII.GetBytes(AuthorizationConstants.JWT_SECRET_KEY); + var user = await _userManager.FindByNameAsync(userName); + var roles = await _userManager.GetRolesAsync(user); + var claims = new List { new Claim(ClaimTypes.Name, userName) }; + + foreach(var role in roles) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims.ToArray()), + Expires = DateTime.UtcNow.AddDays(7), + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + } +} diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index e0b5a21..c2ff1c9 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -12,6 +12,7 @@ + diff --git a/src/PublicApi/AuthEndpoints/Authenticate.AuthenticateRequest.cs b/src/PublicApi/AuthEndpoints/Authenticate.AuthenticateRequest.cs new file mode 100644 index 0000000..c6cd78d --- /dev/null +++ b/src/PublicApi/AuthEndpoints/Authenticate.AuthenticateRequest.cs @@ -0,0 +1,8 @@ +namespace Microsoft.eShopWeb.PublicApi.AuthEndpoints +{ + public class AuthenticateRequest : BaseRequest + { + public string Username { get; set; } + public string Password { get; set; } + } +} diff --git a/src/PublicApi/AuthEndpoints/Authenticate.AuthenticateResponse.cs b/src/PublicApi/AuthEndpoints/Authenticate.AuthenticateResponse.cs new file mode 100644 index 0000000..4d27ca2 --- /dev/null +++ b/src/PublicApi/AuthEndpoints/Authenticate.AuthenticateResponse.cs @@ -0,0 +1,18 @@ +using System; + +namespace Microsoft.eShopWeb.PublicApi.AuthEndpoints +{ + public class AuthenticateResponse : BaseResponse + { + public AuthenticateResponse(Guid correlationId) : base(correlationId) + { + } + + public AuthenticateResponse() + { + } + + public bool Result { get; set; } + public string Token { get; set; } + } +} diff --git a/src/PublicApi/AuthEndpoints/Authenticate.cs b/src/PublicApi/AuthEndpoints/Authenticate.cs new file mode 100644 index 0000000..5c8bcf7 --- /dev/null +++ b/src/PublicApi/AuthEndpoints/Authenticate.cs @@ -0,0 +1,50 @@ +using Ardalis.ApiEndpoints; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.eShopWeb.ApplicationCore.Constants; +using Microsoft.eShopWeb.ApplicationCore.Entities; +using Microsoft.eShopWeb.ApplicationCore.Interfaces; +using Microsoft.eShopWeb.Infrastructure.Identity; +using Microsoft.IdentityModel.Tokens; +using Swashbuckle.AspNetCore.Annotations; +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.PublicApi.AuthEndpoints +{ + public class Authenticate : BaseAsyncEndpoint + { + private readonly SignInManager _signInManager; + private readonly ITokenClaimsService _tokenClaimsService; + + public Authenticate(SignInManager signInManager, + ITokenClaimsService tokenClaimsService) + { + _signInManager = signInManager; + _tokenClaimsService = tokenClaimsService; + } + + [HttpPost("api/authenticate")] + [SwaggerOperation( + Summary = "Authenticates a user", + Description = "Authenticates a user", + OperationId = "auth.authenticate", + Tags = new[] { "AuthEndpoints" }) + ] + public override async Task> HandleAsync(AuthenticateRequest request) + { + var response = new AuthenticateResponse(request.CorrelationId()); + + var result = await _signInManager.PasswordSignInAsync(request.Username, request.Password, false, true); + + response.Result = result.Succeeded; + + response.Token = await _tokenClaimsService.GetTokenAsync(request.Username); + + return response; + } + } +} diff --git a/src/Web/API/BaseRequest.cs b/src/PublicApi/BaseMessage.cs similarity index 77% rename from src/Web/API/BaseRequest.cs rename to src/PublicApi/BaseMessage.cs index 06eb1c6..5ffcd21 100644 --- a/src/Web/API/BaseRequest.cs +++ b/src/PublicApi/BaseMessage.cs @@ -1,6 +1,6 @@ using System; -namespace Microsoft.eShopWeb.Web.API +namespace Microsoft.eShopWeb.PublicApi { /// /// Base class used by API requests @@ -13,8 +13,4 @@ namespace Microsoft.eShopWeb.Web.API protected Guid _correlationId = Guid.NewGuid(); public Guid CorrelationId() => _correlationId; } - - public abstract class BaseRequest : BaseMessage - { - } } diff --git a/src/PublicApi/BaseRequest.cs b/src/PublicApi/BaseRequest.cs new file mode 100644 index 0000000..f7c75fb --- /dev/null +++ b/src/PublicApi/BaseRequest.cs @@ -0,0 +1,9 @@ +namespace Microsoft.eShopWeb.PublicApi +{ + /// + /// Base class used by API requests + /// + public abstract class BaseRequest : BaseMessage + { + } +} diff --git a/src/Web/API/BaseResponse.cs b/src/PublicApi/BaseResponse.cs similarity index 89% rename from src/Web/API/BaseResponse.cs rename to src/PublicApi/BaseResponse.cs index d2c8535..4ce849a 100644 --- a/src/Web/API/BaseResponse.cs +++ b/src/PublicApi/BaseResponse.cs @@ -1,6 +1,6 @@ using System; -namespace Microsoft.eShopWeb.Web.API +namespace Microsoft.eShopWeb.PublicApi { /// /// Base class used by API responses diff --git a/src/Web/API/CatalogItemEndpoints/CatalogItemDto.cs b/src/PublicApi/CatalogItemEndpoints/CatalogItemDto.cs similarity index 85% rename from src/Web/API/CatalogItemEndpoints/CatalogItemDto.cs rename to src/PublicApi/CatalogItemEndpoints/CatalogItemDto.cs index 9a3a197..ed8e302 100644 --- a/src/Web/API/CatalogItemEndpoints/CatalogItemDto.cs +++ b/src/PublicApi/CatalogItemEndpoints/CatalogItemDto.cs @@ -1,4 +1,4 @@ -namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints { public class CatalogItemDto { diff --git a/src/Web/API/CatalogItemEndpoints/Create.CreateCatalogItemRequest.cs b/src/PublicApi/CatalogItemEndpoints/Create.CreateCatalogItemRequest.cs similarity index 84% rename from src/Web/API/CatalogItemEndpoints/Create.CreateCatalogItemRequest.cs rename to src/PublicApi/CatalogItemEndpoints/Create.CreateCatalogItemRequest.cs index f5e168c..3a8e9a4 100644 --- a/src/Web/API/CatalogItemEndpoints/Create.CreateCatalogItemRequest.cs +++ b/src/PublicApi/CatalogItemEndpoints/Create.CreateCatalogItemRequest.cs @@ -1,4 +1,4 @@ -namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints { public class CreateCatalogItemRequest : BaseRequest { diff --git a/src/Web/API/CatalogItemEndpoints/Create.CreateCatalogItemResponse.cs b/src/PublicApi/CatalogItemEndpoints/Create.CreateCatalogItemResponse.cs similarity index 67% rename from src/Web/API/CatalogItemEndpoints/Create.CreateCatalogItemResponse.cs rename to src/PublicApi/CatalogItemEndpoints/Create.CreateCatalogItemResponse.cs index 9b22c63..e0c2f45 100644 --- a/src/Web/API/CatalogItemEndpoints/Create.CreateCatalogItemResponse.cs +++ b/src/PublicApi/CatalogItemEndpoints/Create.CreateCatalogItemResponse.cs @@ -1,6 +1,6 @@ using System; -namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints { public class CreateCatalogItemResponse : BaseResponse { @@ -8,6 +8,10 @@ namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints { } + public CreateCatalogItemResponse() + { + } + public CatalogItemDto CatalogItem { get; set; } } } diff --git a/src/Web/API/CatalogItemEndpoints/Create.cs b/src/PublicApi/CatalogItemEndpoints/Create.cs similarity index 83% rename from src/Web/API/CatalogItemEndpoints/Create.cs rename to src/PublicApi/CatalogItemEndpoints/Create.cs index 133690b..f7a44af 100644 --- a/src/Web/API/CatalogItemEndpoints/Create.cs +++ b/src/PublicApi/CatalogItemEndpoints/Create.cs @@ -1,12 +1,16 @@ using Ardalis.ApiEndpoints; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.eShopWeb.ApplicationCore.Constants; using Microsoft.eShopWeb.ApplicationCore.Entities; using Microsoft.eShopWeb.ApplicationCore.Interfaces; using Swashbuckle.AspNetCore.Annotations; using System.Threading.Tasks; -namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints { + [Authorize(Roles = AuthorizationConstants.Roles.ADMINISTRATORS, AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] public class Create : BaseAsyncEndpoint { private readonly IAsyncRepository _itemRepository; diff --git a/src/Web/API/CatalogItemEndpoints/Delete.DeleteCatalogItemRequest.cs b/src/PublicApi/CatalogItemEndpoints/Delete.DeleteCatalogItemRequest.cs similarity index 74% rename from src/Web/API/CatalogItemEndpoints/Delete.DeleteCatalogItemRequest.cs rename to src/PublicApi/CatalogItemEndpoints/Delete.DeleteCatalogItemRequest.cs index 7e59de7..29aecd9 100644 --- a/src/Web/API/CatalogItemEndpoints/Delete.DeleteCatalogItemRequest.cs +++ b/src/PublicApi/CatalogItemEndpoints/Delete.DeleteCatalogItemRequest.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Mvc; -namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints { public class DeleteCatalogItemRequest : BaseRequest { diff --git a/src/Web/API/CatalogItemEndpoints/Delete.DeleteCatalogItemResponse.cs b/src/PublicApi/CatalogItemEndpoints/Delete.DeleteCatalogItemResponse.cs similarity index 67% rename from src/Web/API/CatalogItemEndpoints/Delete.DeleteCatalogItemResponse.cs rename to src/PublicApi/CatalogItemEndpoints/Delete.DeleteCatalogItemResponse.cs index 40bed9a..33954b2 100644 --- a/src/Web/API/CatalogItemEndpoints/Delete.DeleteCatalogItemResponse.cs +++ b/src/PublicApi/CatalogItemEndpoints/Delete.DeleteCatalogItemResponse.cs @@ -1,6 +1,6 @@ using System; -namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints { public class DeleteCatalogItemResponse : BaseResponse { @@ -8,6 +8,10 @@ namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints { } + public DeleteCatalogItemResponse() + { + } + public string Status { get; set; } = "Deleted"; } } diff --git a/src/Web/API/CatalogItemEndpoints/Delete.cs b/src/PublicApi/CatalogItemEndpoints/Delete.cs similarity index 79% rename from src/Web/API/CatalogItemEndpoints/Delete.cs rename to src/PublicApi/CatalogItemEndpoints/Delete.cs index 323bea5..ed3c712 100644 --- a/src/Web/API/CatalogItemEndpoints/Delete.cs +++ b/src/PublicApi/CatalogItemEndpoints/Delete.cs @@ -1,12 +1,16 @@ using Ardalis.ApiEndpoints; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.eShopWeb.ApplicationCore.Constants; using Microsoft.eShopWeb.ApplicationCore.Entities; using Microsoft.eShopWeb.ApplicationCore.Interfaces; using Swashbuckle.AspNetCore.Annotations; using System.Threading.Tasks; -namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints { + [Authorize(Roles = AuthorizationConstants.Roles.ADMINISTRATORS, AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] public class Delete : BaseAsyncEndpoint { private readonly IAsyncRepository _itemRepository; diff --git a/src/Web/API/CatalogItemEndpoints/GetById.GetByIdCatalogItemRequest.cs b/src/PublicApi/CatalogItemEndpoints/GetById.GetByIdCatalogItemRequest.cs similarity index 65% rename from src/Web/API/CatalogItemEndpoints/GetById.GetByIdCatalogItemRequest.cs rename to src/PublicApi/CatalogItemEndpoints/GetById.GetByIdCatalogItemRequest.cs index b935c97..27f85f9 100644 --- a/src/Web/API/CatalogItemEndpoints/GetById.GetByIdCatalogItemRequest.cs +++ b/src/PublicApi/CatalogItemEndpoints/GetById.GetByIdCatalogItemRequest.cs @@ -1,4 +1,4 @@ -namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints { public class GetByIdCatalogItemRequest : BaseRequest { diff --git a/src/Web/API/CatalogItemEndpoints/GetById.GetByIdCatalogItemResponse.cs b/src/PublicApi/CatalogItemEndpoints/GetById.GetByIdCatalogItemResponse.cs similarity index 67% rename from src/Web/API/CatalogItemEndpoints/GetById.GetByIdCatalogItemResponse.cs rename to src/PublicApi/CatalogItemEndpoints/GetById.GetByIdCatalogItemResponse.cs index ebdd3e0..ebc7c78 100644 --- a/src/Web/API/CatalogItemEndpoints/GetById.GetByIdCatalogItemResponse.cs +++ b/src/PublicApi/CatalogItemEndpoints/GetById.GetByIdCatalogItemResponse.cs @@ -1,6 +1,6 @@ using System; -namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints { public class GetByIdCatalogItemResponse : BaseResponse { @@ -8,6 +8,10 @@ namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints { } + public GetByIdCatalogItemResponse() + { + } + public CatalogItemDto CatalogItem { get; set; } } } diff --git a/src/Web/API/CatalogItemEndpoints/GetById.cs b/src/PublicApi/CatalogItemEndpoints/GetById.cs similarity index 96% rename from src/Web/API/CatalogItemEndpoints/GetById.cs rename to src/PublicApi/CatalogItemEndpoints/GetById.cs index ff28eaf..f34f1dc 100644 --- a/src/Web/API/CatalogItemEndpoints/GetById.cs +++ b/src/PublicApi/CatalogItemEndpoints/GetById.cs @@ -5,7 +5,7 @@ using Microsoft.eShopWeb.ApplicationCore.Interfaces; using Swashbuckle.AspNetCore.Annotations; using System.Threading.Tasks; -namespace Microsoft.eShopWeb.Web.API.CatalogItemEndpoints +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints { public class GetById : BaseAsyncEndpoint { diff --git a/src/PublicApi/CatalogItemEndpoints/ListPaged.ListPagedCatalogItemRequest.cs b/src/PublicApi/CatalogItemEndpoints/ListPaged.ListPagedCatalogItemRequest.cs new file mode 100644 index 0000000..a0451b9 --- /dev/null +++ b/src/PublicApi/CatalogItemEndpoints/ListPaged.ListPagedCatalogItemRequest.cs @@ -0,0 +1,10 @@ +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints +{ + public class ListPagedCatalogItemRequest : BaseRequest + { + public int PageSize { get; set; } + public int PageIndex { get; set; } + public int? CatalogBrandId { get; set; } + public int? CatalogTypeId { get; set; } + } +} diff --git a/src/PublicApi/CatalogItemEndpoints/ListPaged.ListPagedCatalogItemResponse.cs b/src/PublicApi/CatalogItemEndpoints/ListPaged.ListPagedCatalogItemResponse.cs new file mode 100644 index 0000000..9d95ee6 --- /dev/null +++ b/src/PublicApi/CatalogItemEndpoints/ListPaged.ListPagedCatalogItemResponse.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints +{ + public class ListPagedCatalogItemResponse : BaseResponse + { + public ListPagedCatalogItemResponse(Guid correlationId) : base(correlationId) + { + } + + public ListPagedCatalogItemResponse() + { + } + + public List CatalogItems { get; set; } = new List(); + public int PageCount { get; set; } + } +} diff --git a/src/PublicApi/CatalogItemEndpoints/ListPaged.cs b/src/PublicApi/CatalogItemEndpoints/ListPaged.cs new file mode 100644 index 0000000..0cd916e --- /dev/null +++ b/src/PublicApi/CatalogItemEndpoints/ListPaged.cs @@ -0,0 +1,61 @@ +using Ardalis.ApiEndpoints; +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using Microsoft.eShopWeb.ApplicationCore.Entities; +using Microsoft.eShopWeb.ApplicationCore.Interfaces; +using Microsoft.eShopWeb.ApplicationCore.Specifications; +using Swashbuckle.AspNetCore.Annotations; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints +{ + public class ListPaged : BaseAsyncEndpoint + { + private readonly IAsyncRepository _itemRepository; + private readonly IUriComposer _uriComposer; + private readonly IMapper _mapper; + + public ListPaged(IAsyncRepository itemRepository, + IUriComposer uriComposer, + IMapper mapper) + { + _itemRepository = itemRepository; + _uriComposer = uriComposer; + _mapper = mapper; + } + + [HttpGet("api/catalog-items")] + [SwaggerOperation( + Summary = "List Catalog Items (paged)", + Description = "List Catalog Items (paged)", + OperationId = "catalog-items.ListPaged", + Tags = new[] { "CatalogItemEndpoints" }) + ] + public override async Task> HandleAsync([FromQuery]ListPagedCatalogItemRequest request) + { + var response = new ListPagedCatalogItemResponse(request.CorrelationId()); + + var filterSpec = new CatalogFilterSpecification(request.CatalogBrandId, request.CatalogTypeId); + int totalItems = await _itemRepository.CountAsync(filterSpec); + + var pagedSpec = new CatalogFilterPaginatedSpecification( + skip: request.PageIndex * request.PageSize, + take: request.PageSize, + brandId: request.CatalogBrandId, + typeId: request.CatalogTypeId); + + var items = await _itemRepository.ListAsync(pagedSpec); + + response.CatalogItems.AddRange(items.Select(_mapper.Map)); + foreach (CatalogItemDto item in response.CatalogItems) + { + item.PictureUri = _uriComposer.ComposePicUri(item.PictureUri); + } + response.PageCount = int.Parse(Math.Ceiling((decimal)totalItems / request.PageSize).ToString()); + + return Ok(response); + } + } +} diff --git a/src/Web/API/CustomSchemaFilters.cs b/src/PublicApi/CustomSchemaFilters.cs similarity index 92% rename from src/Web/API/CustomSchemaFilters.cs rename to src/PublicApi/CustomSchemaFilters.cs index 490bb6c..854e8e3 100644 --- a/src/Web/API/CustomSchemaFilters.cs +++ b/src/PublicApi/CustomSchemaFilters.cs @@ -1,7 +1,7 @@ using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; -namespace Microsoft.eShopWeb.Web.API +namespace Microsoft.eShopWeb.PublicApi { public class CustomSchemaFilters : ISchemaFilter { diff --git a/src/PublicApi/Dockerfile b/src/PublicApi/Dockerfile new file mode 100644 index 0000000..c140e78 --- /dev/null +++ b/src/PublicApi/Dockerfile @@ -0,0 +1,22 @@ +#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. + +FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build +WORKDIR /src +COPY ["src/PublicApi/PublicApi.csproj", "src/PublicApi/"] +RUN dotnet restore "src/PublicApi/PublicApi.csproj" +COPY . . +WORKDIR "/src/src/PublicApi" +RUN dotnet build "PublicApi.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "PublicApi.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "PublicApi.dll"] \ No newline at end of file diff --git a/src/PublicApi/MappingProfile.cs b/src/PublicApi/MappingProfile.cs new file mode 100644 index 0000000..59d9d4e --- /dev/null +++ b/src/PublicApi/MappingProfile.cs @@ -0,0 +1,14 @@ +using AutoMapper; +using Microsoft.eShopWeb.ApplicationCore.Entities; +using Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints; + +namespace Microsoft.eShopWeb.PublicApi +{ + public class MappingProfile : Profile + { + public MappingProfile() + { + CreateMap(); + } + } +} diff --git a/src/PublicApi/Program.cs b/src/PublicApi/Program.cs new file mode 100644 index 0000000..9d9b469 --- /dev/null +++ b/src/PublicApi/Program.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.eShopWeb.Infrastructure.Data; +using Microsoft.eShopWeb.Infrastructure.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.PublicApi +{ + public class Program + { + public async static Task Main(string[] args) + { + var host = CreateHostBuilder(args) + .Build(); + + using (var scope = host.Services.CreateScope()) + { + var services = scope.ServiceProvider; + var loggerFactory = services.GetRequiredService(); + try + { + var catalogContext = services.GetRequiredService(); + await CatalogContextSeed.SeedAsync(catalogContext, loggerFactory); + + var userManager = services.GetRequiredService>(); + var roleManager = services.GetRequiredService>(); + await AppIdentityDbContextSeed.SeedAsync(userManager, roleManager); + } + catch (Exception ex) + { + var logger = loggerFactory.CreateLogger(); + logger.LogError(ex, "An error occurred seeding the DB."); + } + } + + host.Run(); + } + + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/src/PublicApi/Properties/launchSettings.json b/src/PublicApi/Properties/launchSettings.json new file mode 100644 index 0000000..b3e9eeb --- /dev/null +++ b/src/PublicApi/Properties/launchSettings.json @@ -0,0 +1,37 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:52023", + "sslPort": 44339 + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "PublicApi": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + }, + "Docker": { + "commandName": "Docker", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", + "publishAllPorts": true, + "useSSL": true + } + } +} \ No newline at end of file diff --git a/src/PublicApi/PublicApi.csproj b/src/PublicApi/PublicApi.csproj new file mode 100644 index 0000000..fb7c28d --- /dev/null +++ b/src/PublicApi/PublicApi.csproj @@ -0,0 +1,39 @@ + + + + netcoreapp3.1 + Microsoft.eShopWeb.PublicApi + 5b662463-1efd-4bae-bde4-befe0be3e8ff + Linux + ..\.. + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/Web/API/README.md b/src/PublicApi/README.md similarity index 100% rename from src/Web/API/README.md rename to src/PublicApi/README.md diff --git a/src/PublicApi/Startup.cs b/src/PublicApi/Startup.cs new file mode 100644 index 0000000..854565e --- /dev/null +++ b/src/PublicApi/Startup.cs @@ -0,0 +1,193 @@ +using System.Collections.Generic; +using System.Text; +using AutoMapper; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.eShopWeb.ApplicationCore.Constants; +using Microsoft.eShopWeb.ApplicationCore.Interfaces; +using Microsoft.eShopWeb.ApplicationCore.Services; +using Microsoft.eShopWeb.Infrastructure.Data; +using Microsoft.eShopWeb.Infrastructure.Identity; +using Microsoft.eShopWeb.Infrastructure.Logging; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; + +namespace Microsoft.eShopWeb.PublicApi +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureDevelopmentServices(IServiceCollection services) + { + // use in-memory database + ConfigureInMemoryDatabases(services); + + // use real database + //ConfigureProductionServices(services); + } + + private void ConfigureInMemoryDatabases(IServiceCollection services) + { + // use in-memory database + services.AddDbContext(c => + c.UseInMemoryDatabase("Catalog")); + + // Add Identity DbContext + services.AddDbContext(options => + options.UseInMemoryDatabase("Identity")); + + ConfigureServices(services); + } + + public void ConfigureProductionServices(IServiceCollection services) + { + // use real database + // Requires LocalDB which can be installed with SQL Server Express 2016 + // https://www.microsoft.com/en-us/download/details.aspx?id=54284 + services.AddDbContext(c => + c.UseSqlServer(Configuration.GetConnectionString("CatalogConnection"))); + + // Add Identity DbContext + services.AddDbContext(options => + options.UseSqlServer(Configuration.GetConnectionString("IdentityConnection"))); + + ConfigureServices(services); + } + + public void ConfigureTestingServices(IServiceCollection services) + { + ConfigureInMemoryDatabases(services); + } + + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + services.AddScoped(typeof(IAsyncRepository<>), typeof(EfRepository<>)); + services.Configure(Configuration); + services.AddSingleton(new UriComposer(Configuration.Get())); + services.AddScoped(typeof(IAppLogger<>), typeof(LoggerAdapter<>)); + services.AddScoped(); + + // Add memory cache services + services.AddMemoryCache(); + + // https://stackoverflow.com/questions/46938248/asp-net-core-2-0-combining-cookies-and-bearer-authorization-for-the-same-endpoin + var key = Encoding.ASCII.GetBytes(AuthorizationConstants.JWT_SECRET_KEY); + services.AddAuthentication(config => + { + //config.DefaultScheme = "smart"; + //config.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + //config.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + + config.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme; + config.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + config.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + config.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(x => + { + x.RequireHttpsMetadata = false; + x.SaveToken = true; + x.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = false, + ValidateAudience = false + }; + }); + + + + + services.AddControllers(); + + services.AddAutoMapper(typeof(Startup).Assembly); + + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }); + c.EnableAnnotations(); + c.SchemaFilter(); + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = @"JWT Authorization header using the Bearer scheme. \r\n\r\n + Enter 'Bearer' [space] and then your token in the text input below. + \r\n\r\nExample: 'Bearer 12345abcdef'", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer" + }); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement() + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + }, + Scheme = "oauth2", + Name = "Bearer", + In = ParameterLocation.Header, + + }, + new List() + } + }); + }); + + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseHttpsRedirection(); + + app.UseRouting(); + + app.UseAuthorization(); + + // Enable middleware to serve generated Swagger as a JSON endpoint. + app.UseSwagger(); + + // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), + // specifying the Swagger JSON endpoint. + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); + }); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + } +} diff --git a/src/PublicApi/appsettings.Development.json b/src/PublicApi/appsettings.Development.json new file mode 100644 index 0000000..8983e0f --- /dev/null +++ b/src/PublicApi/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/PublicApi/appsettings.json b/src/PublicApi/appsettings.json new file mode 100644 index 0000000..fab9773 --- /dev/null +++ b/src/PublicApi/appsettings.json @@ -0,0 +1,16 @@ +{ + "ConnectionStrings": { + "CatalogConnection": "Server=(localdb)\\mssqllocaldb;Integrated Security=true;Initial Catalog=Microsoft.eShopOnWeb.CatalogDb;", + "IdentityConnection": "Server=(localdb)\\mssqllocaldb;Integrated Security=true;Initial Catalog=Microsoft.eShopOnWeb.Identity;" + }, + "CatalogBaseUrl": "", + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Warning", + "Microsoft": "Warning", + "System": "Warning" + }, + "AllowedHosts": "*" + } +} diff --git a/src/Web/Areas/Identity/Pages/Account/Logout.cshtml.cs b/src/Web/Areas/Identity/Pages/Account/Logout.cshtml.cs index ee12cbc..eb7f275 100644 --- a/src/Web/Areas/Identity/Pages/Account/Logout.cshtml.cs +++ b/src/Web/Areas/Identity/Pages/Account/Logout.cshtml.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using AutoMapper.Configuration.Annotations; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -9,6 +10,7 @@ using Microsoft.Extensions.Logging; namespace Microsoft.eShopWeb.Web.Areas.Identity.Pages.Account { [AllowAnonymous] + [IgnoreAntiforgeryToken] public class LogoutModel : PageModel { private readonly SignInManager _signInManager; diff --git a/src/Web/Controllers/Api/BaseApiController.cs b/src/Web/Controllers/Api/BaseApiController.cs index aead274..79fb634 100644 --- a/src/Web/Controllers/Api/BaseApiController.cs +++ b/src/Web/Controllers/Api/BaseApiController.cs @@ -2,6 +2,7 @@ namespace Microsoft.eShopWeb.Web.Controllers.Api { + // No longer used - shown for reference only if using full controllers instead of Endpoints for APIs [Route("api/[controller]/[action]")] [ApiController] public class BaseApiController : ControllerBase diff --git a/src/Web/Controllers/Api/CatalogController.cs b/src/Web/Controllers/Api/CatalogController.cs deleted file mode 100644 index cbf3444..0000000 --- a/src/Web/Controllers/Api/CatalogController.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.eShopWeb.Web.Services; -using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; - -namespace Microsoft.eShopWeb.Web.Controllers.Api -{ - public class CatalogController : BaseApiController - { - private readonly ICatalogViewModelService _catalogViewModelService; - - public CatalogController(ICatalogViewModelService catalogViewModelService) => _catalogViewModelService = catalogViewModelService; - - [HttpGet] - public async Task List(int? brandFilterApplied, int? typesFilterApplied, int? page) - { - var itemsPage = 10; - var catalogModel = await _catalogViewModelService.GetCatalogItems(page ?? 0, itemsPage, brandFilterApplied, typesFilterApplied); - return Ok(catalogModel); - } - } -} diff --git a/src/Web/Controllers/OrderController.cs b/src/Web/Controllers/OrderController.cs index c876fbf..d88302a 100644 --- a/src/Web/Controllers/OrderController.cs +++ b/src/Web/Controllers/OrderController.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.eShopWeb.Web.Features.MyOrders; diff --git a/src/Web/Pages/Basket/Checkout.cshtml.cs b/src/Web/Pages/Basket/Checkout.cshtml.cs index 19bab7f..6a9402e 100644 --- a/src/Web/Pages/Basket/Checkout.cshtml.cs +++ b/src/Web/Pages/Basket/Checkout.cshtml.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; diff --git a/src/Web/Startup.cs b/src/Web/Startup.cs index 6053871..66c097c 100644 --- a/src/Web/Startup.cs +++ b/src/Web/Startup.cs @@ -8,7 +8,10 @@ using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.EntityFrameworkCore; using Microsoft.eShopWeb.Infrastructure.Data; using Microsoft.eShopWeb.Infrastructure.Identity; -using Microsoft.eShopWeb.Web.API; +using Microsoft.eShopWeb.Infrastructure.Logging; +using Microsoft.eShopWeb.Infrastructure.Services; +using Microsoft.eShopWeb.Web.Interfaces; +using Microsoft.eShopWeb.Web.Services; using Microsoft.eShopWeb.Web.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -107,12 +110,6 @@ namespace Microsoft.eShopWeb.Web }); services.AddControllersWithViews(); services.AddHttpContextAccessor(); - services.AddSwaggerGen(c => - { - c.SwaggerDoc("v1", new OpenApi.Models.OpenApiInfo { Title = "My API", Version = "v1" }); - c.EnableAnnotations(); - c.SchemaFilter(); - }); services.AddHealthChecks(); services.Configure(config => { @@ -166,15 +163,6 @@ namespace Microsoft.eShopWeb.Web app.UseCookiePolicy(); app.UseAuthentication(); app.UseAuthorization(); - // Enable middleware to serve generated Swagger as a JSON endpoint. - app.UseSwagger(); - - // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), - // specifying the Swagger JSON endpoint. - app.UseSwaggerUI(c => - { - c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); - }); app.UseEndpoints(endpoints => { diff --git a/src/Web/Web.csproj b/src/Web/Web.csproj index e4fb3be..8dc02ce 100644 --- a/src/Web/Web.csproj +++ b/src/Web/Web.csproj @@ -23,6 +23,7 @@ + @@ -30,17 +31,17 @@ - - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/FunctionalTests/FunctionalTests.csproj b/tests/FunctionalTests/FunctionalTests.csproj index ef7ae1f..38a13ce 100644 --- a/tests/FunctionalTests/FunctionalTests.csproj +++ b/tests/FunctionalTests/FunctionalTests.csproj @@ -26,6 +26,7 @@ + @@ -33,8 +34,4 @@ - - - - diff --git a/tests/FunctionalTests/PublicApi/ApiTestFixture.cs b/tests/FunctionalTests/PublicApi/ApiTestFixture.cs new file mode 100644 index 0000000..4443a71 --- /dev/null +++ b/tests/FunctionalTests/PublicApi/ApiTestFixture.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.eShopWeb.Infrastructure.Data; +using Microsoft.eShopWeb.Infrastructure.Identity; +using Microsoft.eShopWeb.PublicApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System; + +namespace Microsoft.eShopWeb.FunctionalTests.PublicApi +{ + public class ApiTestFixture : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + services.AddEntityFrameworkInMemoryDatabase(); + + // Create a new service provider. + var provider = services + .AddEntityFrameworkInMemoryDatabase() + .BuildServiceProvider(); + + // Add a database context (ApplicationDbContext) using an in-memory + // database for testing. + services.AddDbContext(options => + { + options.UseInMemoryDatabase("InMemoryDbForTesting"); + options.UseInternalServiceProvider(provider); + }); + + services.AddDbContext(options => + { + options.UseInMemoryDatabase("Identity"); + options.UseInternalServiceProvider(provider); + }); + + // Build the service provider. + var sp = services.BuildServiceProvider(); + + // Create a scope to obtain a reference to the database + // context (ApplicationDbContext). + using (var scope = sp.CreateScope()) + { + var scopedServices = scope.ServiceProvider; + var db = scopedServices.GetRequiredService(); + var loggerFactory = scopedServices.GetRequiredService(); + + var logger = scopedServices + .GetRequiredService>(); + + // Ensure the database is created. + db.Database.EnsureCreated(); + + try + { + // Seed the database with test data. + CatalogContextSeed.SeedAsync(db, loggerFactory).Wait(); + + // seed sample user data + var userManager = scopedServices.GetRequiredService>(); + var roleManager = scopedServices.GetRequiredService>(); + AppIdentityDbContextSeed.SeedAsync(userManager, roleManager).Wait(); + } + catch (Exception ex) + { + logger.LogError(ex, $"An error occurred seeding the " + + "database with test messages. Error: {ex.Message}"); + } + } + }); + } + } +} diff --git a/tests/FunctionalTests/PublicApi/ApiTokenHelper.cs b/tests/FunctionalTests/PublicApi/ApiTokenHelper.cs new file mode 100644 index 0000000..5ddf62e --- /dev/null +++ b/tests/FunctionalTests/PublicApi/ApiTokenHelper.cs @@ -0,0 +1,51 @@ +using Microsoft.eShopWeb.ApplicationCore.Constants; +using Microsoft.eShopWeb.Infrastructure.Identity; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace Microsoft.eShopWeb.FunctionalTests.Web.Api +{ + public class ApiTokenHelper + { + public static string GetAdminUserToken() + { + string userName = "admin@microsoft.com"; + string[] roles = { "Administrators" }; + + return CreateToken(userName, roles); + } + + public static string GetNormalUserToken() + { + string userName = "demouser@microsoft.com"; + string[] roles = { }; + + return CreateToken(userName, roles); + } + + private static string CreateToken(string userName, string[] roles) + { + var claims = new List { new Claim(ClaimTypes.Name, userName) }; + + foreach (var role in roles) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + + var key = Encoding.ASCII.GetBytes(AuthorizationConstants.JWT_SECRET_KEY); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims.ToArray()), + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) + }; + var tokenHandler = new JwtSecurityTokenHandler(); + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + } +} diff --git a/tests/FunctionalTests/PublicApi/AuthEndpoints/AuthenticateEndpoint.cs b/tests/FunctionalTests/PublicApi/AuthEndpoints/AuthenticateEndpoint.cs new file mode 100644 index 0000000..4232e9c --- /dev/null +++ b/tests/FunctionalTests/PublicApi/AuthEndpoints/AuthenticateEndpoint.cs @@ -0,0 +1,43 @@ +using Microsoft.eShopWeb.ApplicationCore.Constants; +using Microsoft.eShopWeb.FunctionalTests.PublicApi; +using Microsoft.eShopWeb.PublicApi.AuthEndpoints; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.eShopWeb.FunctionalTests.Web.Controllers +{ + [Collection("Sequential")] + public class AuthenticateEndpoint : IClassFixture + { + JsonSerializerOptions _jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + public AuthenticateEndpoint(ApiTestFixture factory) + { + Client = factory.CreateClient(); + } + + public HttpClient Client { get; } + + [Theory] + [InlineData("demouser@microsoft.com", AuthorizationConstants.DEFAULT_PASSWORD, true)] + [InlineData("demouser@microsoft.com", "badpassword", false)] + public async Task ReturnsExpectedResultGivenCredentials(string testUsername, string testPassword, bool expectedResult) + { + var request = new AuthenticateRequest() + { + Username = testUsername, + Password = testPassword + }; + var jsonContent = new StringContent(JsonSerializer.Serialize(request), Encoding.UTF8, "application/json"); + var response = await Client.PostAsync("api/authenticate", jsonContent); + response.EnsureSuccessStatusCode(); + var stringResponse = await response.Content.ReadAsStringAsync(); + var model = stringResponse.FromJson(); + + Assert.Equal(expectedResult, model.Result); + } + } +} diff --git a/tests/FunctionalTests/Web/Controllers/ApiCatalogControllerList.cs b/tests/FunctionalTests/PublicApi/CatalogItemEndpoints/ApiCatalogControllerList.cs similarity index 69% rename from tests/FunctionalTests/Web/Controllers/ApiCatalogControllerList.cs rename to tests/FunctionalTests/PublicApi/CatalogItemEndpoints/ApiCatalogControllerList.cs index c457a54..702d830 100644 --- a/tests/FunctionalTests/Web/Controllers/ApiCatalogControllerList.cs +++ b/tests/FunctionalTests/PublicApi/CatalogItemEndpoints/ApiCatalogControllerList.cs @@ -1,18 +1,16 @@ -using Microsoft.eShopWeb.Web.ViewModels; +using Microsoft.eShopWeb.FunctionalTests.PublicApi; +using Microsoft.eShopWeb.Web.ViewModels; using System.Linq; using System.Net.Http; -using System.Text.Json; using System.Threading.Tasks; using Xunit; namespace Microsoft.eShopWeb.FunctionalTests.Web.Controllers { [Collection("Sequential")] - public class ApiCatalogControllerList : IClassFixture + public class ApiCatalogControllerList : IClassFixture { - JsonSerializerOptions _jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - - public ApiCatalogControllerList(WebTestFixture factory) + public ApiCatalogControllerList(ApiTestFixture factory) { Client = factory.CreateClient(); } @@ -22,7 +20,7 @@ namespace Microsoft.eShopWeb.FunctionalTests.Web.Controllers [Fact] public async Task ReturnsFirst10CatalogItems() { - var response = await Client.GetAsync("/api/catalog/list"); + var response = await Client.GetAsync("/api/catalog-items?pageSize=10"); response.EnsureSuccessStatusCode(); var stringResponse = await response.Content.ReadAsStringAsync(); var model = stringResponse.FromJson(); @@ -33,7 +31,7 @@ namespace Microsoft.eShopWeb.FunctionalTests.Web.Controllers [Fact] public async Task ReturnsLast2CatalogItemsGivenPageIndex1() { - var response = await Client.GetAsync("/api/catalog/list?page=1"); + var response = await Client.GetAsync("/api/catalog-items?pageSize=10&pageIndex=1"); response.EnsureSuccessStatusCode(); var stringResponse = await response.Content.ReadAsStringAsync(); var model = stringResponse.FromJson(); diff --git a/tests/FunctionalTests/PublicApi/CatalogItemEndpoints/CreateEndpoint.cs b/tests/FunctionalTests/PublicApi/CatalogItemEndpoints/CreateEndpoint.cs new file mode 100644 index 0000000..55d33d4 --- /dev/null +++ b/tests/FunctionalTests/PublicApi/CatalogItemEndpoints/CreateEndpoint.cs @@ -0,0 +1,78 @@ +using Microsoft.eShopWeb.FunctionalTests.PublicApi; +using Microsoft.eShopWeb.FunctionalTests.Web.Api; +using Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.eShopWeb.FunctionalTests.Web.Controllers +{ + [Collection("Sequential")] + public class CreateEndpoint : IClassFixture + { + JsonSerializerOptions _jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + private int _testBrandId = 1; + private int _testTypeId = 2; + private string _testDescription = "test description"; + private string _testName = "test name"; + private string _testUri = "test uri"; + private decimal _testPrice = 1.23m; + + public CreateEndpoint(ApiTestFixture factory) + { + Client = factory.CreateClient(); + } + + public HttpClient Client { get; } + + [Fact] + public async Task ReturnsNotAuthorizedGivenNormalUserToken() + { + var jsonContent = GetValidNewItemJson(); + var token = ApiTokenHelper.GetNormalUserToken(); + Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + var response = await Client.PostAsync("api/catalog-items", jsonContent); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task ReturnsSuccessGivenValidNewItemAndAdminUserToken() + { + var jsonContent = GetValidNewItemJson(); + var adminToken = ApiTokenHelper.GetAdminUserToken(); + Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); + var response = await Client.PostAsync("api/catalog-items", jsonContent); + response.EnsureSuccessStatusCode(); + var stringResponse = await response.Content.ReadAsStringAsync(); + var model = stringResponse.FromJson(); + + Assert.Equal(_testBrandId, model.CatalogItem.CatalogBrandId); + Assert.Equal(_testTypeId, model.CatalogItem.CatalogTypeId); + Assert.Equal(_testDescription, model.CatalogItem.Description); + Assert.Equal(_testName, model.CatalogItem.Name); + Assert.Equal(_testUri, model.CatalogItem.PictureUri); + Assert.Equal(_testPrice, model.CatalogItem.Price); + } + + private StringContent GetValidNewItemJson() + { + var request = new CreateCatalogItemRequest() + { + CatalogBrandId = _testBrandId, + CatalogTypeId = _testTypeId, + Description = _testDescription, + Name = _testName, + PictureUri = _testUri, + Price = _testPrice + }; + var jsonContent = new StringContent(JsonSerializer.Serialize(request), Encoding.UTF8, "application/json"); + + return jsonContent; + } + } +} diff --git a/tests/FunctionalTests/PublicApi/CatalogItemEndpoints/DeleteEndpoint.cs b/tests/FunctionalTests/PublicApi/CatalogItemEndpoints/DeleteEndpoint.cs new file mode 100644 index 0000000..8c23a86 --- /dev/null +++ b/tests/FunctionalTests/PublicApi/CatalogItemEndpoints/DeleteEndpoint.cs @@ -0,0 +1,48 @@ +using Microsoft.eShopWeb.FunctionalTests.PublicApi; +using Microsoft.eShopWeb.FunctionalTests.Web.Api; +using Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.eShopWeb.FunctionalTests.Web.Controllers +{ + [Collection("Sequential")] + public class DeleteEndpoint : IClassFixture + { + JsonSerializerOptions _jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + public DeleteEndpoint(ApiTestFixture factory) + { + Client = factory.CreateClient(); + } + + public HttpClient Client { get; } + + [Fact] + public async Task ReturnsSuccessGivenValidIdAndAdminUserToken() + { + var adminToken = ApiTokenHelper.GetAdminUserToken(); + Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); + var response = await Client.DeleteAsync("api/catalog-items/12"); + response.EnsureSuccessStatusCode(); + var stringResponse = await response.Content.ReadAsStringAsync(); + var model = stringResponse.FromJson(); + + Assert.Equal("Deleted", model.Status); + } + + [Fact] + public async Task ReturnsNotFoundGivenInvalidIdAndAdminUserToken() + { + var adminToken = ApiTokenHelper.GetAdminUserToken(); + Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); + var response = await Client.DeleteAsync("api/catalog-items/0"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + } +} diff --git a/tests/FunctionalTests/PublicApi/CatalogItemEndpoints/GetByIdEndpoint.cs b/tests/FunctionalTests/PublicApi/CatalogItemEndpoints/GetByIdEndpoint.cs new file mode 100644 index 0000000..cd373d3 --- /dev/null +++ b/tests/FunctionalTests/PublicApi/CatalogItemEndpoints/GetByIdEndpoint.cs @@ -0,0 +1,43 @@ +using Microsoft.eShopWeb.FunctionalTests.PublicApi; +using Microsoft.eShopWeb.PublicApi.CatalogItemEndpoints; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.eShopWeb.FunctionalTests.Web.Controllers +{ + [Collection("Sequential")] + public class GetByIdEndpoint : IClassFixture + { + JsonSerializerOptions _jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + public GetByIdEndpoint(ApiTestFixture factory) + { + Client = factory.CreateClient(); + } + + public HttpClient Client { get; } + + [Fact] + public async Task ReturnsItemGivenValidId() + { + var response = await Client.GetAsync("api/catalog-items/5"); + response.EnsureSuccessStatusCode(); + var stringResponse = await response.Content.ReadAsStringAsync(); + var model = stringResponse.FromJson(); + + Assert.Equal(5, model.CatalogItem.Id); + Assert.Equal("Roslyn Red Sheet", model.CatalogItem.Name); + } + + [Fact] + public async Task ReturnsNotFoundGivenInvalidId() + { + var response = await Client.GetAsync("api/catalog-items/0"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + } +}