diff options
Diffstat (limited to 'server/src/Utilities')
| -rw-r--r-- | server/src/Utilities/BasicAuthenticationAttribute.cs | 39 | ||||
| -rw-r--r-- | server/src/Utilities/BasicAuthenticationHandler.cs | 79 | ||||
| -rw-r--r-- | server/src/Utilities/ConfigurationExtensions.cs | 37 | ||||
| -rw-r--r-- | server/src/Utilities/GithubAuthenticationHelpers.cs | 84 | ||||
| -rw-r--r-- | server/src/Utilities/QuartzJsonSerializer.cs | 16 | ||||
| -rw-r--r-- | server/src/Utilities/SwaggerDefaultValues.cs | 58 | ||||
| -rw-r--r-- | server/src/Utilities/SwaggerGenOptionsExtensions.cs | 43 |
7 files changed, 356 insertions, 0 deletions
diff --git a/server/src/Utilities/BasicAuthenticationAttribute.cs b/server/src/Utilities/BasicAuthenticationAttribute.cs new file mode 100644 index 0000000..0bfd007 --- /dev/null +++ b/server/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/server/src/Utilities/BasicAuthenticationHandler.cs b/server/src/Utilities/BasicAuthenticationHandler.cs new file mode 100644 index 0000000..2b9d9ef --- /dev/null +++ b/server/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 AppDbContext _context; + private readonly IConfiguration _configuration; + private readonly ILogger<BasicAuthenticationHandler> _logger; + + public BasicAuthenticationHandler( + IOptionsMonitor<AuthenticationSchemeOptions> options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + AppDbContext context, + IConfiguration configuration + ) : + base(options, logger, encoder, clock) { + _context = context; + _configuration = configuration; + _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 token_entropy = _configuration.GetValue<string>("TOKEN_ENTROPY"); + if (token_entropy.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 decrypted_string = Encoding.UTF8.GetString(credentialBytes).DecryptWithAes(token_entropy); + var token_is_guid = Guid.TryParse(decrypted_string, out var token_id); + + if (!token_is_guid) { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } + + var token = _context.AccessTokens.Include(c => c.User).SingleOrDefault(c => c.Id == token_id); + 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/server/src/Utilities/ConfigurationExtensions.cs b/server/src/Utilities/ConfigurationExtensions.cs new file mode 100644 index 0000000..772059a --- /dev/null +++ b/server/src/Utilities/ConfigurationExtensions.cs @@ -0,0 +1,37 @@ +namespace IOL.GreatOffice.Api.Utilities; + +public static class ConfigurationExtensions +{ + public static string GetAppDatabaseConnectionString(this IConfiguration configuration) { + var host = configuration.GetValue<string>(AppEnvironmentVariables.DB_HOST); + var port = configuration.GetValue<string>(AppEnvironmentVariables.DB_PORT); + var database = configuration.GetValue<string>(AppEnvironmentVariables.DB_NAME); + var user = configuration.GetValue<string>(AppEnvironmentVariables.DB_USER); + var password = configuration.GetValue<string>(AppEnvironmentVariables.DB_PASSWORD); + + if (configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development") { + return $"Server={host};Port={port};Database={database};User Id={user};Password={password};Include Error Detail=true"; + } + + return $"Server={host};Port={port};Database={database};User Id={user};Password={password}"; + } + + public static string GetQuartzDatabaseConnectionString(this IConfiguration Configuration) { + var host = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_HOST); + var port = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PORT); + var database = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_NAME); + var user = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_USER); + var password = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PASSWORD); + return $"Server={host};Port={port};Database={database};User Id={user};Password={password}"; + } + + public static string GetVersion(this IConfiguration configuration) { + var versionFilePath = Path.Combine(AppPaths.AppData.HostPath, "version.txt"); + if (File.Exists(versionFilePath)) { + var versionText = File.ReadAllText(versionFilePath); + return versionText + "-" + configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT"); + } + + return "unknown-" + configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT"); + } +} diff --git a/server/src/Utilities/GithubAuthenticationHelpers.cs b/server/src/Utilities/GithubAuthenticationHelpers.cs new file mode 100644 index 0000000..cf0cabb --- /dev/null +++ b/server/src/Utilities/GithubAuthenticationHelpers.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Authentication.OAuth; +using Npgsql; + +namespace IOL.GreatOffice.Api.Utilities; + +public static class GithubAuthenticationHelpers +{ + public static async Task HandleGithubTicketCreation(OAuthCreatingTicketContext context, IConfiguration configuration) { + var githubId = context.Identity?.FindFirst(p => p.Type == ClaimTypes.NameIdentifier)?.Value; + var githubUsername = context.Identity?.FindFirst(p => p.Type == ClaimTypes.Name)?.Value; + var githubEmail = context.Identity?.FindFirst(p => p.Type == ClaimTypes.Email)?.Value; + + if (githubId.IsNullOrWhiteSpace() || githubUsername.IsNullOrWhiteSpace() || context.Identity == default) { + return; + } + + var claims = context.Identity.Claims.ToList(); + foreach (var claim in claims) { + context.Identity.RemoveClaim(claim); + } + + var connstring = configuration.GetAppDatabaseConnectionString(); + var connection = new NpgsqlConnection(connstring); + + Log.Information($"Getting user mappings for github user: {githubId}"); + var getMappedUserQuery = @$"SELECT u.id,u.username FROM github_user_mappings INNER JOIN users u on u.id = github_user_mappings.user_id WHERE github_id='{githubId}'"; + await connection.OpenAsync(); + await using var getMappedUserCommand = new NpgsqlCommand(getMappedUserQuery, connection); + await using var reader = await getMappedUserCommand.ExecuteReaderAsync(); + var handled = false; + while (await reader.ReadAsync()) { + try { + var userId = reader.GetGuid(0); + var username = reader.GetString(1); + context.Identity.AddClaim(new Claim(AppClaims.USER_ID, userId.ToString())); + context.Identity.AddClaim(new Claim(AppClaims.NAME, username)); + if (context.AccessToken != default) { + context.Identity.AddClaim(new Claim(AppClaims.GITHUB_ACCESS_TOKEN, context.AccessToken ?? "")); + } + + Log.Information($"Found mapping for github id {githubId} mapped to user id {userId}"); + handled = true; + } catch (Exception e) { + Log.Error(e, "An exception occured when handling github user mappings"); + handled = false; + } + } + + await connection.CloseAsync(); + + if (!handled) { + var userId = Guid.NewGuid(); + + var insertUserQuery = $@"INSERT INTO users VALUES ('{userId}', '{githubUsername}', '', '{DateTime.UtcNow}')"; + await connection.OpenAsync(); + await using var insertUserCommand = new NpgsqlCommand(insertUserQuery, connection); + await insertUserCommand.ExecuteNonQueryAsync(); + await connection.CloseAsync(); + + var refreshTokenEncryptionKey = configuration.GetValue<string>(AppEnvironmentVariables.APP_AES_KEY); + string insertMappingQuery; + + if (context.RefreshToken.HasValue() && refreshTokenEncryptionKey.HasValue()) { + var encryptedRefreshToken = context.RefreshToken.EncryptWithAes(refreshTokenEncryptionKey); + insertMappingQuery = $@"INSERT INTO github_user_mappings VALUES ('{githubId}', '{userId}', '{githubEmail}', '{encryptedRefreshToken}')"; + } else { + insertMappingQuery = $@"INSERT INTO github_user_mappings VALUES ('{githubId}', '{userId}', '{githubEmail}', '')"; + } + + await connection.OpenAsync(); + await using var insertMappingCommand = new NpgsqlCommand(insertMappingQuery, connection); + await insertMappingCommand.ExecuteNonQueryAsync(); + await connection.CloseAsync(); + + context.Identity.AddClaim(new Claim(AppClaims.USER_ID, userId.ToString())); + context.Identity.AddClaim(new Claim(AppClaims.NAME, githubUsername)); + if (context.AccessToken != default) { + context.Identity.AddClaim(new Claim(AppClaims.GITHUB_ACCESS_TOKEN, context.AccessToken ?? "")); + } + + Log.Information($"Created mapping for github id {githubId} mapped to user id {userId}"); + } + } +} diff --git a/server/src/Utilities/QuartzJsonSerializer.cs b/server/src/Utilities/QuartzJsonSerializer.cs new file mode 100644 index 0000000..164a189 --- /dev/null +++ b/server/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/server/src/Utilities/SwaggerDefaultValues.cs b/server/src/Utilities/SwaggerDefaultValues.cs new file mode 100644 index 0000000..4b5c764 --- /dev/null +++ b/server/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.Any(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/server/src/Utilities/SwaggerGenOptionsExtensions.cs b/server/src/Utilities/SwaggerGenOptionsExtensions.cs new file mode 100644 index 0000000..a2dcf7a --- /dev/null +++ b/server/src/Utilities/SwaggerGenOptionsExtensions.cs @@ -0,0 +1,43 @@ +#nullable enable +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Swashbuckle.AspNetCore.SwaggerGen; +using BaseRoute = IOL.GreatOffice.Api.Endpoints.V1.BaseRoute; + +namespace IOL.GreatOffice.Api.Utilities; + +public static class SwaggerGenOptionsExtensions +{ + /// <summary> + /// Updates Swagger document to support ApiEndpoints.<br/><br/> + /// For controllers inherited from <see cref="BaseRoute"/>:<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(BaseRoute))) { + return new[] { + actionDescriptor.ControllerTypeInfo.Namespace?.Split('.').Last() + }; + } + + return new[] { + actionDescriptor.ControllerName + }; + } + + public static IEnumerable<Type> GetBaseTypesAndThis(this Type type) { + Type? current = type; + while (current != null) { + yield return current; + current = current.BaseType; + } + } +} |
