diff options
11 files changed, 284 insertions, 23 deletions
diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/DatabaseHelper.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/DatabaseHelper.cs new file mode 100644 index 0000000..9eb6a08 --- /dev/null +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/DatabaseHelper.cs @@ -0,0 +1,13 @@ +namespace CrupestApi.Commons; + + +public static class DatabaseHelper +{ + public static string GenerateUpdateColumnString(this IEnumerable<string> updateColumnList, IEnumerable<KeyValuePair<string, string>>? paramNameMap = null) + { + paramNameMap = paramNameMap ?? Enumerable.Empty<KeyValuePair<string, string>>(); + var paramNameDictionary = new Dictionary<string, string>(paramNameMap); + + return string.Join(", ", updateColumnList.Select(x => $"{x} = @{paramNameDictionary.GetValueOrDefault(x) ?? x}")); + } +}
\ No newline at end of file diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/EntityNotExistException.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/EntityNotExistException.cs new file mode 100644 index 0000000..0e1f4f4 --- /dev/null +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/EntityNotExistException.cs @@ -0,0 +1,8 @@ +namespace CrupestApi.Commons; + +public class EntityNotExistException : Exception +{ + public EntityNotExistException() { } + public EntityNotExistException(string message) : base(message) { } + public EntityNotExistException(string message, Exception inner) : base(message, inner) { } +} diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/HttpResponseAction.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/HttpResponseAction.cs index 4b76066..768a6d2 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/HttpResponseAction.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/HttpResponseAction.cs @@ -1,3 +1,4 @@ namespace CrupestApi.Commons; -public delegate Task HttpResponseAction(HttpResponse response); +public delegate void HttpResponseAction(HttpResponse response); +public delegate Task AsyncHttpResponseAction(HttpResponse response); diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Json.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Json.cs index 8409a96..1d8106c 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Json.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Json.cs @@ -18,8 +18,16 @@ public static class CrupestApiJsonExtensions return services; } + public static async Task WriteJsonAsync<T>(this HttpResponse response, T bodyObject, int statusCode, HttpResponseAction? beforeWriteBody, CancellationToken cancellationToken = default) + { + await response.WriteJsonAsync(bodyObject, statusCode, (context) => + { + beforeWriteBody?.Invoke(context); + return Task.CompletedTask; + }, cancellationToken); + } - public static async Task WriteJsonAsync<T>(this HttpResponse response, T bodyObject, int statusCode = 200, HttpResponseAction? beforeWriteBody = null, CancellationToken cancellationToken = default) + public static async Task WriteJsonAsync<T>(this HttpResponse response, T bodyObject, int statusCode = 200, AsyncHttpResponseAction? beforeWriteBody = null, CancellationToken cancellationToken = default) { var jsonOptions = response.HttpContext.RequestServices.GetRequiredService<IOptionsSnapshot<JsonSerializerOptions>>(); byte[] json = JsonSerializer.SerializeToUtf8Bytes<T>(bodyObject, jsonOptions.Value); @@ -36,4 +44,9 @@ public static class CrupestApiJsonExtensions await response.Body.WriteAsync(json, cancellationToken); } + + public static async Task WriteMessageAsync(this HttpResponse response, string message, int statusCode = 200, HttpResponseAction? beforeWriteBody = null, CancellationToken cancellationToken = default) + { + await response.WriteJsonAsync(new ErrorBody(message), statusCode: statusCode, beforeWriteBody, cancellationToken); + } } diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/ISecretsService.cs b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/ISecretsService.cs index c42fbdc..b5de436 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/ISecretsService.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/ISecretsService.cs @@ -2,15 +2,17 @@ namespace CrupestApi.Secrets; public interface ISecretsService { + Task<SecretInfo?> GetSecretAsync(string secret); + Task<List<SecretInfo>> GetSecretListAsync(bool includeExpired = false, bool includeRevoked = false); Task<List<SecretInfo>> GetSecretListByKeyAsync(string key, bool includeExpired = false, bool includeRevoked = false); - Task<bool> VerifySecretAsync(string key, string secret); + Task VerifySecretAsync(string? key, string? secret); // Check if "secret" query param exists and is only one. Then check the secret is valid for given key. // If check fails, will throw a VerifySecretException with proper message that can be send to client. - Task VerifySecretForHttpRequestAsync(HttpRequest request, string key, string queryKey = "secret"); + Task VerifySecretForHttpRequestAsync(HttpRequest request, string? key = null, string queryKey = "secret"); Task<SecretInfo> CreateSecretAsync(string key, string description, DateTime? expireTime = null); diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretCreateRequest.cs b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretCreateRequest.cs new file mode 100644 index 0000000..5d0ea51 --- /dev/null +++ b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretCreateRequest.cs @@ -0,0 +1,9 @@ +namespace CrupestApi.Secrets; + +public class SecretCreateRequest +{ + public string Key { get; set; } = default!; + public string Secret { get; set; } = default!; + public string Description { get; set; } = default!; + public DateTime? ExpireTime { get; set; } +}
\ No newline at end of file diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretInfo.cs b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretInfo.cs index 46ce501..0fe95cb 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretInfo.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretInfo.cs @@ -7,15 +7,15 @@ public class SecretInfo Key = key; Secret = secret; Description = description; - ExpireTime = expireTime; + ExpireTime = expireTime?.ToString("O"); Revoked = revoked; - CreateTime = createdTime; + CreateTime = createdTime.ToString("O"); } public string Key { get; set; } public string Secret { get; set; } public string Description { get; set; } - public DateTime? ExpireTime { get; set; } + public string? ExpireTime { get; set; } public bool Revoked { get; set; } - public DateTime CreateTime { get; set; } + public string CreateTime { get; set; } } diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretModifyRequest.cs b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretModifyRequest.cs index dfff347..f632c6d 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretModifyRequest.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretModifyRequest.cs @@ -15,16 +15,23 @@ public class SecretModifyRequest ExpireTime = null; } - public SecretModifyRequest(string? key, string? description, DateTime? expireTime) + public SecretModifyRequest(string? key, string? description, DateTime? expireTime, bool revoked) { + if (revoked is not true) + { + throw new ArgumentException("Revoked can only be set to true."); + } + Key = key; Description = description; SetExpireTime = true; ExpireTime = expireTime; + Revoked = revoked; } public string? Key { get; set; } public string? Description { get; set; } public bool SetExpireTime { get; set; } public DateTime? ExpireTime { get; set; } + public bool Revoked { get; set; } } diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsService.cs b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsService.cs index feac08a..5cdcc54 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsService.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsService.cs @@ -1,3 +1,5 @@ +using System.Data; +using System.Diagnostics; using System.Security.Cryptography; using System.Text; using CrupestApi.Commons; @@ -86,6 +88,25 @@ INSERT INTO secrets (Key, Secret, Description, ExpireTime, Revoked, CreateTime) return result.ToString(); } + private async Task<SecretInfo> GetSecretAsync(IDbConnection dbConnection, string secret) + { + var result = await dbConnection.QueryFirstOrDefaultAsync<SecretInfo>(@" +SELECT Id, Key, Secret, Description, ExpireTime, Revoked, CreateTime FROM secrets WHERE Secret = @Secret; + ", new + { + Secret = secret + }); + + return result; + + } + + public async Task<SecretInfo?> GetSecretAsync(string secret) + { + using var dbConnection = await EnsureDatabase(); + return await GetSecretAsync(dbConnection, secret); + } + public async Task<SecretInfo> CreateSecretAsync(string key, string description, DateTime? expireTime = null) { var dbConnection = await EnsureDatabase(); @@ -146,23 +167,148 @@ WHERE Key = @Key AND return query.ToList(); } - public Task<SecretInfo> ModifySecretAsync(string secret, SecretModifyRequest modifyRequest) + public async Task<SecretInfo> ModifySecretAsync(string secret, SecretModifyRequest modifyRequest) { - throw new NotImplementedException(); + var dbConnection = await EnsureDatabase(); + + var secretInfo = await GetSecretAsync(dbConnection, secret); + + if (secretInfo is null) + { + throw new EntityNotExistException("Secret not found."); + } + + var queryParams = new DynamicParameters(); + var updateColumnList = new List<string>(); + + if (modifyRequest.Key is not null) + { + queryParams.Add("Key", modifyRequest.Key); + updateColumnList.Add("Key"); + } + + if (modifyRequest.Description is not null) + { + queryParams.Add("Description", modifyRequest.Description); + updateColumnList.Add("Description"); + } + + if (modifyRequest.SetExpireTime is true) + { + queryParams.Add("ExpireTime", modifyRequest.ExpireTime?.ToString("O")); + updateColumnList.Add("ExpireTime"); + } + + if (modifyRequest.Revoked is true && secretInfo.Revoked is not true) + { + queryParams.Add("Revoked", true); + updateColumnList.Add("Revoked"); + } + + if (updateColumnList.Count == 0) + { + return secretInfo; + } + + queryParams.Add("Secret", secret); + + var updateColumnString = updateColumnList.GenerateUpdateColumnString(); + + var changeCount = await dbConnection.ExecuteAsync($@" +UPDATE secrets SET {updateColumnString} WHERE Secret = @Secret; + ", queryParams); + + Debug.Assert(changeCount == 1); + + return secretInfo; } - public Task RevokeSecretAsync(string secret) + public async Task RevokeSecretAsync(string secret) { - throw new NotImplementedException(); + await ModifySecretAsync(secret, new SecretModifyRequest + { + Revoked = true, + }); } - public Task<bool> VerifySecretAsync(string key, string secret) + public async Task VerifySecretAsync(string? key, string? secret) { - throw new NotImplementedException(); + var dbConnection = await EnsureDatabase(); + + if (secret is null) + { + if (key is not null) + { + throw new VerifySecretException(key, "A secret with given key is needed."); + } + } + + var entity = await dbConnection.QueryFirstOrDefaultAsync<SecretInfo>(@" +SELECT Id, Key, Secret, Description, ExpireTime, Revoked, CreateTime FROM secrets WHERE Key = @Key AND Secret = @Secret + ", new + { + Key = key, + Secret = secret, + }); + + if (entity is null) + { + throw new VerifySecretException(key, "Secret token is invalid."); + } + + if (entity.Revoked is true) + { + throw new VerifySecretException(key, "Secret token is revoked."); + } + + if (entity.ExpireTime is not null && DateTime.ParseExact(entity.ExpireTime, "O", null) > DateTime.Now) + { + throw new VerifySecretException(key, "Secret token is expired."); + } + + if (key is not null) + { + if (entity.Key != key) + { + throw new VerifySecretException(key, "Secret is not for this key", VerifySecretException.ErrorKind.Forbidden); + } + } } - public Task VerifySecretForHttpRequestAsync(HttpRequest request, string key, string queryKey = "secret") + public async Task VerifySecretForHttpRequestAsync(HttpRequest request, string? key, string queryKey = "secret") { - throw new NotImplementedException(); + string? secret = null; + + var authorizationHeaders = request.Headers.Authorization.ToList(); + if (authorizationHeaders.Count > 1) + { + _logger.LogWarning("There are multiple Authorization headers in the request. Will use the last one."); + } + if (authorizationHeaders.Count > 0) + { + var authorizationHeader = authorizationHeaders[^1] ?? ""; + if (!authorizationHeader.StartsWith("Bearer ")) + { + throw new VerifySecretException(key, "Authorization header must start with 'Bearer '."); + } + + secret = authorizationHeader.Substring("Bearer ".Length).Trim(); + } + + var secretQueryParam = request.Query[queryKey].ToList(); + if (secretQueryParam.Count > 1) + { + _logger.LogWarning($"There are multiple '{queryKey}' query parameters in the request. Will use the last one."); + } + if (secretQueryParam.Count > 0) + { + if (secret is not null) + { + _logger.LogWarning("Secret found both in Authorization header and query parameter. Will use the one in query parameter."); + } + secret = secretQueryParam[^1] ?? ""; + } + + await VerifySecretAsync(key, secret); } } diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsWebApplicationExtensions.cs b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsWebApplicationExtensions.cs index a771547..12d939b 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsWebApplicationExtensions.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsWebApplicationExtensions.cs @@ -14,17 +14,17 @@ public static class SecretsWebApplicationExtensions } catch (VerifySecretException e) { - await context.Response.WriteErrorMessageAsync(e.Message, 401); + await context.Response.WriteErrorMessageAsync(e.Message, e.Kind == VerifySecretException.ErrorKind.Unauthorized ? 401 : 403); } }); return app; } - public static async Task CheckSecret(this HttpContext context, string key) + public static async Task CheckSecret(this HttpContext context, string? key) { var secretsService = context.RequestServices.GetRequiredService<ISecretsService>(); - await secretsService.VerifySecretForHttpRequestAsync(context.Request, SecretsConstants.SecretManagementKey); + await secretsService.VerifySecretForHttpRequestAsync(context.Request, key); } public static WebApplication MapSecrets(this WebApplication app, string path) @@ -37,6 +37,59 @@ public static class SecretsWebApplicationExtensions await context.Response.WriteJsonAsync(secrets); }); + app.MapGet(path + "/:secret", async (context) => + { + await context.CheckSecret(SecretsConstants.SecretManagementKey); + var secretsService = context.RequestServices.GetRequiredService<ISecretsService>(); + var secret = context.Request.RouteValues["secret"]; + if (secret is null) + { + await context.Response.WriteErrorMessageAsync("Secret path parameter is invalid."); + return; + } + var secretInfo = secretsService.GetSecretAsync((string)secret); + await context.Response.WriteJsonAsync(secretInfo); + }); + + app.MapPost(path, async (context) => + { + await context.CheckSecret(SecretsConstants.SecretManagementKey); + var secretsService = context.RequestServices.GetRequiredService<ISecretsService>(); + var request = await context.Request.ReadFromJsonAsync<SecretCreateRequest>(); + if (request is null) + { + await context.Response.WriteErrorMessageAsync("Failed to deserialize request body to SecretCreateRequest."); + return; + } + var secret = await secretsService.CreateSecretAsync(request.Key, request.Description, request.ExpireTime); + await context.Response.WriteJsonAsync(secret, 201, beforeWriteBody: (response) => + { + response.Headers.Location = context.Request.Path + "/" + secret.Secret; + }); + }); + + app.MapPost(path + "/:secret/revoke", async (context) => + { + await context.CheckSecret(SecretsConstants.SecretManagementKey); + var secretsService = context.RequestServices.GetRequiredService<ISecretsService>(); + var secret = context.Request.RouteValues["secret"]; + if (secret is null) + { + await context.Response.WriteErrorMessageAsync("Secret path parameter is invalid."); + return; + } + + try + { + await secretsService.RevokeSecretAsync((string)secret); + await context.Response.WriteMessageAsync("Secret revoked."); + } + catch (EntityNotExistException) + { + await context.Response.WriteErrorMessageAsync("Secret to revoke is invalid."); + } + }); + return app; } } diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/VerifySecretException.cs b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/VerifySecretException.cs index c9f60a1..795fa3e 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/VerifySecretException.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/VerifySecretException.cs @@ -2,10 +2,19 @@ namespace CrupestApi.Secrets; public class VerifySecretException : Exception { - public VerifySecretException(string requestKey, string message) : base(message) + public VerifySecretException(string? requestKey, string message, ErrorKind kind = ErrorKind.Unauthorized) : base(message) { RequestKey = requestKey; + Kind = kind; } - public string RequestKey { get; set; } -}
\ No newline at end of file + public enum ErrorKind + { + Unauthorized, + Forbidden + } + + public ErrorKind Kind { get; set; } + + public string? RequestKey { get; set; } +} |