diff options
Diffstat (limited to 'code/api/src/Utilities')
| -rw-r--r-- | code/api/src/Utilities/BasicAuthenticationAttribute.cs | 39 | ||||
| -rw-r--r-- | code/api/src/Utilities/BasicAuthenticationHandler.cs | 79 | ||||
| -rw-r--r-- | code/api/src/Utilities/ConfigurationExtensions.cs | 85 | ||||
| -rw-r--r-- | code/api/src/Utilities/DateTimeExtensions.cs | 8 | ||||
| -rw-r--r-- | code/api/src/Utilities/PaginationOperationFilter.cs | 39 | ||||
| -rw-r--r-- | code/api/src/Utilities/QuartzJsonSerializer.cs | 16 | ||||
| -rw-r--r-- | code/api/src/Utilities/QueryableExtensions.cs | 12 | ||||
| -rw-r--r-- | code/api/src/Utilities/SwaggerDefaultValues.cs | 58 | ||||
| -rw-r--r-- | code/api/src/Utilities/SwaggerGenOptionsExtensions.cs | 43 |
9 files changed, 379 insertions, 0 deletions
diff --git a/code/api/src/Utilities/BasicAuthenticationAttribute.cs b/code/api/src/Utilities/BasicAuthenticationAttribute.cs new file mode 100644 index 0000000..0bfd007 --- /dev/null +++ b/code/api/src/Utilities/BasicAuthenticationAttribute.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace IOL.GreatOffice.Api.Utilities; + +public class BasicAuthenticationAttribute : TypeFilterAttribute +{ + public BasicAuthenticationAttribute(string claimPermission) : base(typeof(BasicAuthenticationFilter)) { + Arguments = new object[] { + new Claim(claimPermission, "True") + }; + } +} + +public class BasicAuthenticationFilter : IAuthorizationFilter +{ + private readonly Claim _claim; + + public BasicAuthenticationFilter(Claim claim) { + _claim = claim; + } + + public void OnAuthorization(AuthorizationFilterContext context) { + if (!context.HttpContext.Request.Headers.ContainsKey("Authorization")) return; + try { + var authHeader = AuthenticationHeaderValue.Parse(context.HttpContext.Request.Headers["Authorization"]); + if (authHeader.Parameter is null) { + context.Result = new ForbidResult(AppConstants.BASIC_AUTH_SCHEME); + } + + var hasClaim = context.HttpContext.User.Claims.Any(c => c.Type == _claim.Type && c.Value == _claim.Value); + if (!hasClaim) { + context.Result = new ForbidResult(AppConstants.BASIC_AUTH_SCHEME); + } + } catch { + // ignore + } + } +} diff --git a/code/api/src/Utilities/BasicAuthenticationHandler.cs b/code/api/src/Utilities/BasicAuthenticationHandler.cs new file mode 100644 index 0000000..b0a2d1a --- /dev/null +++ b/code/api/src/Utilities/BasicAuthenticationHandler.cs @@ -0,0 +1,79 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.Extensions.Options; + +namespace IOL.GreatOffice.Api.Utilities; + +public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> +{ + private readonly MainAppDatabase _context; + private readonly AppConfiguration _configuration; + private readonly ILogger<BasicAuthenticationHandler> _logger; + + public BasicAuthenticationHandler( + IOptionsMonitor<AuthenticationSchemeOptions> options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + MainAppDatabase context, + VaultService vaultService + ) : + base(options, logger, encoder, clock) { + _context = context; + _configuration = vaultService.GetCurrentAppConfiguration(); + _logger = logger.CreateLogger<BasicAuthenticationHandler>(); + } + + protected override Task<AuthenticateResult> HandleAuthenticateAsync() { + var endpoint = Context.GetEndpoint(); + if (endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null) + return Task.FromResult(AuthenticateResult.NoResult()); + + if (!Request.Headers.ContainsKey("Authorization")) + return Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header")); + + try { + var tokenEntropy = _configuration.APP_AES_KEY; + if (tokenEntropy.IsNullOrWhiteSpace()) { + _logger.LogWarning("No token entropy is available in env:TOKEN_ENTROPY, Basic auth is disabled"); + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } + + var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]); + if (authHeader.Parameter == null) return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + var credentialBytes = Convert.FromBase64String(authHeader.Parameter); + var decryptedString = Encoding.UTF8.GetString(credentialBytes).DecryptWithAes(tokenEntropy); + var tokenIsGuid = Guid.TryParse(decryptedString, out var tokenId); + + if (!tokenIsGuid) { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } + + var token = _context.AccessTokens.Include(c => c.User).SingleOrDefault(c => c.Id == tokenId); + if (token == default) { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header: Not Found")); + } + + if (token.HasExpired) { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header: Expired")); + } + + var permissions = new List<Claim>() { + new(AppConstants.TOKEN_ALLOW_READ, token.AllowRead.ToString()), + new(AppConstants.TOKEN_ALLOW_UPDATE, token.AllowUpdate.ToString()), + new(AppConstants.TOKEN_ALLOW_CREATE, token.AllowCreate.ToString()), + new(AppConstants.TOKEN_ALLOW_DELETE, token.AllowDelete.ToString()), + }; + var claims = token.User.DefaultClaims().Concat(permissions); + var identity = new ClaimsIdentity(claims, AppConstants.BASIC_AUTH_SCHEME); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, AppConstants.BASIC_AUTH_SCHEME); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } catch (Exception e) { + _logger.LogError(e, $"An exception occured when challenging {AppConstants.BASIC_AUTH_SCHEME}"); + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } + } +} diff --git a/code/api/src/Utilities/ConfigurationExtensions.cs b/code/api/src/Utilities/ConfigurationExtensions.cs new file mode 100644 index 0000000..c95e293 --- /dev/null +++ b/code/api/src/Utilities/ConfigurationExtensions.cs @@ -0,0 +1,85 @@ +namespace IOL.GreatOffice.Api.Utilities; + +public static class ConfigurationExtensions +{ + public static string GetAppDatabaseConnectionString(this IConfiguration config, AppConfiguration configuration) { + var host = configuration.DB_HOST; + var port = configuration.DB_PORT; + var database = configuration.DB_NAME; + var user = configuration.DB_USER; + var password = configuration.DB_PASSWORD; + + string result; + if (config.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development") { + result = $"Server={host};Port={port};Database={database};User Id={user};Password={password};Include Error Detail=true"; + } else { + result = $"Server={host};Port={port};Database={database};User Id={user};Password={password}"; + } + + Log.Debug("Using app database connection string: " + result); + return result; + } + + public static string GetAppDatabaseConnectionString(this IConfiguration config, Func<AppConfiguration> configuration) { + var _configuration = configuration(); + var host = _configuration.DB_HOST; + var port = _configuration.DB_PORT; + var database = _configuration.DB_NAME; + var user = _configuration.DB_USER; + var password = _configuration.DB_PASSWORD; + + string result; + if (config.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development") { + result = $"Server={host};Port={port};Database={database};User Id={user};Password={password};Include Error Detail=true"; + } else { + result = $"Server={host};Port={port};Database={database};User Id={user};Password={password}"; + } + + Log.Debug("Using app database connection string: " + result); + return result; + } + + public static string GetQuartzDatabaseConnectionString(this IConfiguration config, AppConfiguration configuration) { + var host = configuration.QUARTZ_DB_HOST; + var port = configuration.QUARTZ_DB_PORT; + var database = configuration.QUARTZ_DB_NAME; + var user = configuration.QUARTZ_DB_USER; + var password = configuration.QUARTZ_DB_PASSWORD; + + string result; + if (config.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development") { + result = $"Server={host};Port={port};Database={database};User Id={user};Password={password};Include Error Detail=true"; + } else { + result = $"Server={host};Port={port};Database={database};User Id={user};Password={password}"; + } + + Log.Debug("Using quartz database connection string: " + result); + return result; + } + + public static string GetQuartzDatabaseConnectionString(this IConfiguration config, Func<AppConfiguration> configuration) { + var _configuration = configuration(); + var host = _configuration.QUARTZ_DB_HOST; + var port = _configuration.QUARTZ_DB_PORT; + var database = _configuration.QUARTZ_DB_NAME; + var user = _configuration.QUARTZ_DB_USER; + var password = _configuration.QUARTZ_DB_PASSWORD; + + string result; + if (config.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development") { + result = $"Server={host};Port={port};Database={database};User Id={user};Password={password};Include Error Detail=true"; + } else { + result = $"Server={host};Port={port};Database={database};User Id={user};Password={password}"; + } + + Log.Debug("Using quartz database connection string: " + result); + return result; + } + + public static string GetVersion(this IConfiguration configuration) { + var versionFilePath = Path.Combine(AppPaths.AppData.HostPath, "version.txt"); + if (!File.Exists(versionFilePath)) return "unknown-" + configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT"); + var versionText = File.ReadAllText(versionFilePath); + return versionText + "-" + configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT"); + } +} diff --git a/code/api/src/Utilities/DateTimeExtensions.cs b/code/api/src/Utilities/DateTimeExtensions.cs new file mode 100644 index 0000000..d25e9a8 --- /dev/null +++ b/code/api/src/Utilities/DateTimeExtensions.cs @@ -0,0 +1,8 @@ +namespace IOL.GreatOffice.Api.Utilities; + +public static class DateTimeExtensions +{ + public static bool IsNullOrEmpty(this DateTime dateTime) { + return (dateTime == default); + } +}
\ No newline at end of file diff --git a/code/api/src/Utilities/PaginationOperationFilter.cs b/code/api/src/Utilities/PaginationOperationFilter.cs new file mode 100644 index 0000000..ad02a3d --- /dev/null +++ b/code/api/src/Utilities/PaginationOperationFilter.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Options; +using MR.AspNetCore.Pagination; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace IOL.GreatOffice.Api.Utilities; + +public class PaginationOperationFilter : IOperationFilter +{ + private readonly PaginationOptions _paginationOptions; + + public PaginationOperationFilter( + IOptions<PaginationOptions> paginationOptions) { + _paginationOptions = paginationOptions.Value; + } + + public void Apply(OpenApiOperation operation, OperationFilterContext context) { + var boolSchema = context.SchemaGenerator.GenerateSchema(typeof(bool), context.SchemaRepository); + var intSchema = context.SchemaGenerator.GenerateSchema(typeof(int), context.SchemaRepository); + + if (PaginationActionDetector.IsKeysetPaginationResultAction(context.MethodInfo, out _)) { + CreateParameter(_paginationOptions.FirstQueryParameterName, "true if you want the first page", boolSchema); + CreateParameter(_paginationOptions.BeforeQueryParameterName, "Id of the reference entity you want results before"); + CreateParameter(_paginationOptions.AfterQueryParameterName, "Id of the reference entity you want results after"); + CreateParameter(_paginationOptions.LastQueryParameterName, "true if you want the last page", boolSchema); + } else if (PaginationActionDetector.IsOffsetPaginationResultAction(context.MethodInfo, out _)) { + CreateParameter(_paginationOptions.PageQueryParameterName, "The page", intSchema); + } + + void CreateParameter(string name, string description, OpenApiSchema schema = null) { + operation.Parameters.Add(new OpenApiParameter { + Required = false, + In = ParameterLocation.Query, + Name = name, + Description = description, + Schema = schema, + }); + } + } +}
\ No newline at end of file diff --git a/code/api/src/Utilities/QuartzJsonSerializer.cs b/code/api/src/Utilities/QuartzJsonSerializer.cs new file mode 100644 index 0000000..164a189 --- /dev/null +++ b/code/api/src/Utilities/QuartzJsonSerializer.cs @@ -0,0 +1,16 @@ +using Quartz.Spi; + +namespace IOL.GreatOffice.Api.Utilities; + +public class QuartzJsonSerializer : IObjectSerializer +{ + public void Initialize() { } + + public byte[] Serialize<T>(T obj) where T : class { + return JsonSerializer.SerializeToUtf8Bytes(obj); + } + + public T DeSerialize<T>(byte[] data) where T : class { + return JsonSerializer.Deserialize<T>(data); + } +} diff --git a/code/api/src/Utilities/QueryableExtensions.cs b/code/api/src/Utilities/QueryableExtensions.cs new file mode 100644 index 0000000..bf2bf3b --- /dev/null +++ b/code/api/src/Utilities/QueryableExtensions.cs @@ -0,0 +1,12 @@ +namespace IOL.GreatOffice.Api.Utilities; + +public static class QueryableExtensions +{ + public static IQueryable<T> ForTenant<T>(this IQueryable<T> queryable, LoggedInUserModel loggedInUserModel) where T : BaseWithOwner { + return queryable.Where(c => c.TenantId == loggedInUserModel.TenantId); + } + + public static IQueryable<T> ForUser<T>(this IQueryable<T> queryable, LoggedInUserModel loggedInUserModel) where T : BaseWithOwner { + return queryable.Where(c => c.UserId == loggedInUserModel.Id); + } +}
\ No newline at end of file diff --git a/code/api/src/Utilities/SwaggerDefaultValues.cs b/code/api/src/Utilities/SwaggerDefaultValues.cs new file mode 100644 index 0000000..5e73fa8 --- /dev/null +++ b/code/api/src/Utilities/SwaggerDefaultValues.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace IOL.GreatOffice.Api.Utilities; + +/// <summary> +/// Represents the Swagger/Swashbuckle operation filter used to document the implicit API version parameter. +/// </summary> +/// <remarks>This <see cref="IOperationFilter"/> is only required due to bugs in the <see cref="SwaggerGenerator"/>. +/// Once they are fixed and published, this class can be removed.</remarks> +public class SwaggerDefaultValues : IOperationFilter +{ + /// <summary> + /// Applies the filter to the specified operation using the given context. + /// </summary> + /// <param name="operation">The operation to apply the filter to.</param> + /// <param name="context">The current operation filter context.</param> + public void Apply(OpenApiOperation operation, OperationFilterContext context) { + var apiDescription = context.ApiDescription; + + operation.Deprecated |= apiDescription.IsDeprecated(); + + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1752#issue-663991077 + foreach (var responseType in context.ApiDescription.SupportedResponseTypes) { + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/b7cf75e7905050305b115dd96640ddd6e74c7ac9/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs#L383-L387 + var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString(); + var response = operation.Responses[responseKey]; + + foreach (var contentType in response.Content.Keys) { + if (responseType.ApiResponseFormats.All(x => x.MediaType != contentType)) { + response.Content.Remove(contentType); + } + } + } + + if (operation.Parameters == null) { + return; + } + + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/412 + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413 + foreach (var parameter in operation.Parameters) { + var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name); + + if (parameter.Description == null) { + parameter.Description = description.ModelMetadata.Description; + } + + if (parameter.Schema.Default == null && description.DefaultValue != null) { + // REF: https://github.com/Microsoft/aspnet-api-versioning/issues/429#issuecomment-605402330 + var json = JsonSerializer.Serialize(description.DefaultValue, description.ModelMetadata.ModelType); + parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json); + } + + parameter.Required |= description.IsRequired; + } + } +} diff --git a/code/api/src/Utilities/SwaggerGenOptionsExtensions.cs b/code/api/src/Utilities/SwaggerGenOptionsExtensions.cs new file mode 100644 index 0000000..a3d9036 --- /dev/null +++ b/code/api/src/Utilities/SwaggerGenOptionsExtensions.cs @@ -0,0 +1,43 @@ +#nullable enable +using IOL.GreatOffice.Api.Endpoints; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace IOL.GreatOffice.Api.Utilities; + +public static class SwaggerGenOptionsExtensions +{ + /// <summary> + /// Updates Swagger document to support ApiEndpoints.<br/><br/> + /// For controllers inherited from <see cref="EndpointBase"/>:<br/> + /// - Replaces action Tag with <c>[namespace]</c><br/> + /// </summary> + public static void UseApiEndpoints(this SwaggerGenOptions options) { + options.TagActionsBy(EndpointNamespaceOrDefault); + } + + private static IList<string?> EndpointNamespaceOrDefault(ApiDescription api) { + if (api.ActionDescriptor is not ControllerActionDescriptor actionDescriptor) { + throw new InvalidOperationException($"Unable to determine tag for endpoint: {api.ActionDescriptor.DisplayName}"); + } + + if (actionDescriptor.ControllerTypeInfo.GetBaseTypesAndThis().Any(t => t == typeof(EndpointBase))) { + return new[] { + actionDescriptor.ControllerTypeInfo.Namespace?.Split('.').Last() + }; + } + + return new[] { + actionDescriptor.ControllerName + }; + } + + private static IEnumerable<Type> GetBaseTypesAndThis(this Type type) { + var current = type; + while (current != null) { + yield return current; + current = current.BaseType; + } + } +}
\ No newline at end of file |
