diff options
| author | crupest <crupest@outlook.com> | 2022-12-04 18:11:06 +0800 | 
|---|---|---|
| committer | crupest <crupest@outlook.com> | 2022-12-20 20:32:52 +0800 | 
| commit | 5da5ebbad1d84390009038a9231977d7ed7e2d60 (patch) | |
| tree | 12eda3d48fefb733b11e675724481d75dc65d319 /docker/crupest-api/CrupestApi/CrupestApi.Secrets | |
| parent | 92b93d875c3f61312d2221e3b4d15f5a2e8d7a11 (diff) | |
| download | crupest-5da5ebbad1d84390009038a9231977d7ed7e2d60.tar.gz crupest-5da5ebbad1d84390009038a9231977d7ed7e2d60.tar.bz2 crupest-5da5ebbad1d84390009038a9231977d7ed7e2d60.zip  | |
Develop secret api. v4
Diffstat (limited to 'docker/crupest-api/CrupestApi/CrupestApi.Secrets')
7 files changed, 247 insertions, 21 deletions
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; } +}  | 
