summaryrefslogtreecommitdiffstats
path: root/server/src/Utilities
diff options
context:
space:
mode:
authorivarlovlie <git@ivarlovlie.no>2022-06-01 22:10:32 +0200
committerivarlovlie <git@ivarlovlie.no>2022-06-01 22:10:32 +0200
commita640703f2da8815dc26ad1600a6f206be1624379 (patch)
treedbda195fb5783d16487e557e06471cf848b75427 /server/src/Utilities
downloadgreatoffice-a640703f2da8815dc26ad1600a6f206be1624379.tar.xz
greatoffice-a640703f2da8815dc26ad1600a6f206be1624379.zip
feat: Initial after clean slate
Diffstat (limited to 'server/src/Utilities')
-rw-r--r--server/src/Utilities/BasicAuthenticationAttribute.cs39
-rw-r--r--server/src/Utilities/BasicAuthenticationHandler.cs79
-rw-r--r--server/src/Utilities/ConfigurationExtensions.cs37
-rw-r--r--server/src/Utilities/GithubAuthenticationHelpers.cs84
-rw-r--r--server/src/Utilities/QuartzJsonSerializer.cs16
-rw-r--r--server/src/Utilities/SwaggerDefaultValues.cs58
-rw-r--r--server/src/Utilities/SwaggerGenOptionsExtensions.cs43
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;
+ }
+ }
+}