diff --git a/Readme.md b/Readme.md index 7483477..8458306 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Introduction -![Under Development](https://img.shields.io/badge/status-Under%20Development-orange) ![Build Status](https://circleci.com/gh/dnutiu/retroactiune.svg?style=svg) +![Build Status](https://circleci.com/gh/dnutiu/retroactiune.svg?style=svg) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/dnutiu/retroactiune) ![GitHub repo size](https://img.shields.io/github/repo-size/dnutiu/retroactiune) ![GitHub top language](https://img.shields.io/github/languages/top/dnutiu/retroactiune) ![Swagger API](./docs/retroactiune_swagger.png) @@ -31,6 +31,17 @@ The application code is organized using the [Clean Architecture](https://docs.mi ![Example deployment architecture](./docs/app_architecture_layers.png) +## Authorization Provider + +An external Authorization provider is required in order to run the API, the provider needs to support +Bearer tokens and HS256 key signing algorithm. _RS256 is currently not supported._ + +I recommend that you start with [Auth0](https://auth0.com/) and their free tier. + +See the following resources: +- https://auth0.com/docs/get-started/set-up-apis +- https://developers.redhat.com/blog/2020/01/29/api-login-and-jwt-token-generation-using-keycloak#set_up_a_client + ## Developing To install the dependencies run `dotnet restore`. @@ -48,4 +59,6 @@ _Note: [Docker](https://www.docker.com/) and [Docker-Compose](https://docs.docke ```bash docker-compose up -d dotnet test -``` \ No newline at end of file +``` + +The projects has ~96% code coverage. \ No newline at end of file diff --git a/Retroactiune.IntegrationTests/Retroactiune.IntegrationTests.csproj b/Retroactiune.IntegrationTests/Retroactiune.IntegrationTests.csproj index a52df2b..f775be0 100644 --- a/Retroactiune.IntegrationTests/Retroactiune.IntegrationTests.csproj +++ b/Retroactiune.IntegrationTests/Retroactiune.IntegrationTests.csproj @@ -11,7 +11,7 @@ - + diff --git a/Retroactiune.WebAPI/Controllers/FeedbackReceivers.FeedbacksController.cs b/Retroactiune.WebAPI/Controllers/FeedbackReceivers.FeedbacksController.cs index b624b46..cad6cad 100644 --- a/Retroactiune.WebAPI/Controllers/FeedbackReceivers.FeedbacksController.cs +++ b/Retroactiune.WebAPI/Controllers/FeedbackReceivers.FeedbacksController.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Retroactiune.Core.Entities; @@ -77,9 +78,11 @@ namespace Retroactiune.Controllers /// The feedback has been added. /// The request is invalid. /// + [Authorize] [HttpGet("{guid}/feedbacks")] [ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(BasicResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType( StatusCodes.Status401Unauthorized)] public async Task GetFeedbacks(string guid, [FromQuery] ListFeedbacksFiltersDto filters) { var feedbacksListFilters = _mapper.Map(filters); diff --git a/Retroactiune.WebAPI/Controllers/FeedbackReceiversController.cs b/Retroactiune.WebAPI/Controllers/FeedbackReceiversController.cs index 5576e55..8473a8c 100644 --- a/Retroactiune.WebAPI/Controllers/FeedbackReceiversController.cs +++ b/Retroactiune.WebAPI/Controllers/FeedbackReceiversController.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using AutoMapper; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -46,10 +47,12 @@ namespace Retroactiune.Controllers /// The list of FeedbackReceivers /// A BasicResponse indicating success. /// Returns an ok message. - /// If the items is invalid + /// If the items is invalid + [Authorize] [HttpPost] [ProducesResponseType(typeof(BasicResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType( StatusCodes.Status401Unauthorized)] public async Task Post([Required] IEnumerable items) { var feedbackReceiversDto = items.ToList(); @@ -77,9 +80,11 @@ namespace Retroactiune.Controllers /// A NoContent result. /// The delete is submitted. /// The request is invalid. + [Authorize] [HttpDelete("{guid}")] [ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType( StatusCodes.Status401Unauthorized)] public async Task Delete( [StringLength(24, ErrorMessage = "invalid guid, must be 24 characters", MinimumLength = 24)] string guid) @@ -96,11 +101,13 @@ namespace Retroactiune.Controllers /// A Ok result with a . /// The item returned successfully. /// The request is invalid. - /// The item was not found. + /// The item was not found. + [Authorize] [HttpGet("{guid}")] [ProducesResponseType(typeof(FeedbackReceiverOutDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(BasicResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType( StatusCodes.Status401Unauthorized)] public async Task Get( [StringLength(24, ErrorMessage = "invalid guid, must be 24 characters", MinimumLength = 24)] string guid) @@ -126,10 +133,12 @@ namespace Retroactiune.Controllers /// If set, it will limit the results to N items. Allowed range is 1-1000. /// A Ok result with a list of . /// The a list is returned. - /// The request is invalid. + /// The request is invalid. [HttpGet] + [Authorize] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(typeof(BasicResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType( StatusCodes.Status401Unauthorized)] public async Task List([FromQuery] IEnumerable filter, [RangeAttribute(1, int.MaxValue, ErrorMessage = "offset is out of range, allowed ranges [1-IntMax]"), FromQuery] @@ -148,8 +157,10 @@ namespace Retroactiune.Controllers /// The request is invalid. /// [HttpDelete] + [Authorize] [ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(BasicResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType( StatusCodes.Status401Unauthorized)] public async Task DeleteMany([Required] IEnumerable ids) { try diff --git a/Retroactiune.WebAPI/Controllers/TokensController.cs b/Retroactiune.WebAPI/Controllers/TokensController.cs index c128295..5a3cfc3 100644 --- a/Retroactiune.WebAPI/Controllers/TokensController.cs +++ b/Retroactiune.WebAPI/Controllers/TokensController.cs @@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using AutoMapper; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -39,8 +40,10 @@ namespace Retroactiune.Controllers /// A list of tokens. /// The request is invalid. [HttpGet] + [Authorize] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType( StatusCodes.Status401Unauthorized)] public async Task ListTokens([FromQuery] ListTokensFiltersDto filtersDto) { try @@ -68,8 +71,10 @@ namespace Retroactiune.Controllers /// Returns ok. /// If the items is invalid [HttpPost] + [Authorize] [ProducesResponseType(typeof(BasicResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType( StatusCodes.Status401Unauthorized)] public async Task GenerateTokens([Required] GenerateTokensDto generateTokensDto) { var feedbackReceiverId = generateTokensDto.FeedbackReceiverId; @@ -99,8 +104,10 @@ namespace Retroactiune.Controllers /// The request is invalid. /// [HttpDelete] + [Authorize] [ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(BasicResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType( StatusCodes.Status401Unauthorized)] public async Task DeleteTokens([Required] IEnumerable tokenIds) { try @@ -124,10 +131,12 @@ namespace Retroactiune.Controllers /// The guid of the item to be deleted. /// A NoContent result. /// The delete is submitted. - /// The request is invalid. + /// The request is invalid. + [Authorize] [HttpDelete("{guid}")] [ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType( StatusCodes.Status401Unauthorized)] public async Task DeleteToken( [StringLength(24, ErrorMessage = "invalid guid, must be 24 characters", MinimumLength = 24)] string guid) diff --git a/Retroactiune.WebAPI/Retroactiune.WebAPI.csproj b/Retroactiune.WebAPI/Retroactiune.WebAPI.csproj index 5e6d1d2..41b6737 100644 --- a/Retroactiune.WebAPI/Retroactiune.WebAPI.csproj +++ b/Retroactiune.WebAPI/Retroactiune.WebAPI.csproj @@ -1,26 +1,24 @@ + - - - netcoreapp3.1 - Retroactiune - true - 1591 - - - - - - - - - - - - - - - - - - - + + netcoreapp3.1 + Retroactiune + true + 1591 + 0efb2158-cb38-4e00-9900-48a0fa4ce3c1 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Retroactiune.WebAPI/Startup.cs b/Retroactiune.WebAPI/Startup.cs index 3adbfdf..ada12f8 100644 --- a/Retroactiune.WebAPI/Startup.cs +++ b/Retroactiune.WebAPI/Startup.cs @@ -1,6 +1,8 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -8,6 +10,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; using MongoDB.Driver; using Prometheus; using Retroactiune.Core.Interfaces; @@ -20,8 +24,6 @@ namespace Retroactiune [ExcludeFromCodeCoverage] public class Startup { - // TODO: External auth provider. - // TODO: Improve coverage. // TODO: UI? public Startup(IConfiguration configuration) { @@ -52,12 +54,51 @@ namespace Retroactiune return new MongoClient(settings.Value.ConnectionString); }); + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }).AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = Configuration.GetSection("AuthorizationProvider:Domain").Value, + ValidAudience = Configuration.GetSection("AuthorizationProvider:Audience").Value, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(Configuration.GetSection("AuthorizationProvider:SymmetricSecurityKey") + .Value)) + }; + }); + // WebAPI services.AddControllers(); services.AddSwaggerGen(c => { var filePath = Path.Combine(AppContext.BaseDirectory, "Retroactiune.WebAPI.xml"); c.IncludeXmlComments(filePath); + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + In = ParameterLocation.Header, + Scheme = "bearer", + Description = "Please insert JWT token into field" + }); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + new string[] { } + } + }); }); } @@ -70,7 +111,7 @@ namespace Retroactiune } app.UseMetricServer(); - + app.UseSwagger(); app.UseSwaggerUI(c => { @@ -79,14 +120,14 @@ namespace Retroactiune }); app.UseHttpsRedirection(); - + app.UseSentryTracing(); app.UseRouting(); + app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); - logger.LogInformation("Running"); } } diff --git a/Retroactiune.WebAPI/TestTokenAuthenticationHandler.cs b/Retroactiune.WebAPI/TestTokenAuthenticationHandler.cs new file mode 100644 index 0000000..28a771a --- /dev/null +++ b/Retroactiune.WebAPI/TestTokenAuthenticationHandler.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Retroactiune +{ + public class TestAuthenticationOptions : AuthenticationSchemeOptions + { + } + + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] + public class TestTokenAuthenticationHandler : AuthenticationHandler + { + + public TestTokenAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, + UrlEncoder encoder, ISystemClock clock) + : base(options, logger, encoder, clock) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] {new Claim("token", "allow_all")}; + var identity = new ClaimsIdentity(claims, nameof(TestTokenAuthenticationHandler)); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } +} \ No newline at end of file diff --git a/Retroactiune.WebAPI/TestingStartup.cs b/Retroactiune.WebAPI/TestingStartup.cs index b33feaf..2108ebc 100644 --- a/Retroactiune.WebAPI/TestingStartup.cs +++ b/Retroactiune.WebAPI/TestingStartup.cs @@ -42,7 +42,12 @@ namespace Retroactiune var settings = i.GetService>(); return new MongoClient(settings.Value.ConnectionString); }); - + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = "Bearer"; + }).AddScheme ("Bearer", o => { }); + // WebAPI services.AddControllers(); } @@ -52,6 +57,7 @@ namespace Retroactiune { app.UseHttpsRedirection(); app.UseRouting(); + app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } diff --git a/Retroactiune.WebAPI/appsettings.json b/Retroactiune.WebAPI/appsettings.json index 0a87ca2..3d2d0b6 100644 --- a/Retroactiune.WebAPI/appsettings.json +++ b/Retroactiune.WebAPI/appsettings.json @@ -1,4 +1,9 @@ { + "AuthorizationProvider": { + "Domain": "Your AuthProvider Domain", + "Audience": "Your API Url", + "SymmetricSecurityKey": "HS256 secret" + }, "Sentry": { "Dsn": "", "MaxRequestBodySize": "Always",