aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docker/crupest-api/CrupestApi/CrupestApi.Commons/DatabaseHelper.cs13
-rw-r--r--docker/crupest-api/CrupestApi/CrupestApi.Commons/EntityNotExistException.cs8
-rw-r--r--docker/crupest-api/CrupestApi/CrupestApi.Commons/HttpResponseAction.cs3
-rw-r--r--docker/crupest-api/CrupestApi/CrupestApi.Commons/Json.cs15
-rw-r--r--docker/crupest-api/CrupestApi/CrupestApi.Secrets/ISecretsService.cs6
-rw-r--r--docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretCreateRequest.cs9
-rw-r--r--docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretInfo.cs8
-rw-r--r--docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretModifyRequest.cs9
-rw-r--r--docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsService.cs162
-rw-r--r--docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsWebApplicationExtensions.cs59
-rw-r--r--docker/crupest-api/CrupestApi/CrupestApi.Secrets/VerifySecretException.cs15
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; }
+}