aboutsummaryrefslogtreecommitdiffstats
path: root/code/api/src/Utilities
diff options
context:
space:
mode:
Diffstat (limited to 'code/api/src/Utilities')
-rw-r--r--code/api/src/Utilities/BasicAuthenticationAttribute.cs39
-rw-r--r--code/api/src/Utilities/BasicAuthenticationHandler.cs79
-rw-r--r--code/api/src/Utilities/ConfigurationExtensions.cs85
-rw-r--r--code/api/src/Utilities/DateTimeExtensions.cs8
-rw-r--r--code/api/src/Utilities/PaginationOperationFilter.cs39
-rw-r--r--code/api/src/Utilities/QuartzJsonSerializer.cs16
-rw-r--r--code/api/src/Utilities/QueryableExtensions.cs12
-rw-r--r--code/api/src/Utilities/SwaggerDefaultValues.cs58
-rw-r--r--code/api/src/Utilities/SwaggerGenOptionsExtensions.cs43
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