WIP
This commit is contained in:
13
src/api/MicCheck.Api/ApiKeys/ApiKey.cs
Executable file
13
src/api/MicCheck.Api/ApiKeys/ApiKey.cs
Executable file
@@ -0,0 +1,13 @@
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public class ApiKey
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Key { get; set; }
|
||||
public required string Prefix { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public int OrganizationId { get; init; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
46
src/api/MicCheck.Api/ApiKeys/ApiKeyEndpoints.cs
Executable file
46
src/api/MicCheck.Api/ApiKeys/ApiKeyEndpoints.cs
Executable file
@@ -0,0 +1,46 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public static class ApiKeyEndpoints
|
||||
{
|
||||
public static void MapApiKeyEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app
|
||||
.MapGroup("/api/v1/organisations/{organizationId:int}/api-keys")
|
||||
.RequireAuthorization(AuthorizationPolicies.OrganizationAdmin)
|
||||
.WithTags("ApiKeys");
|
||||
|
||||
group.MapPost("/", async (
|
||||
int organizationId,
|
||||
CreateApiKeyRequest request,
|
||||
ApiKeyService apiKeyService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var (key, rawKey) = await apiKeyService.CreateAsync(
|
||||
organizationId, request.Name, request.ExpiresAt, ct);
|
||||
|
||||
return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt));
|
||||
}).WithName("CreateApiKey");
|
||||
|
||||
group.MapGet("/", async (
|
||||
int organizationId,
|
||||
ApiKeyService apiKeyService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var keys = await apiKeyService.ListAsync(organizationId, ct);
|
||||
return Results.Ok(keys.Select(k =>
|
||||
new ApiKeyResponse(k.Id, k.Name, k.Prefix, k.IsActive, k.ExpiresAt, k.CreatedAt)));
|
||||
}).WithName("ListApiKeys");
|
||||
|
||||
group.MapDelete("/{keyId:int}", async (
|
||||
int organizationId,
|
||||
int keyId,
|
||||
ApiKeyService apiKeyService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
await apiKeyService.RevokeAsync(organizationId, keyId, ct);
|
||||
return Results.NoContent();
|
||||
}).WithName("RevokeApiKey");
|
||||
}
|
||||
}
|
||||
22
src/api/MicCheck.Api/ApiKeys/ApiKeyHasher.cs
Executable file
22
src/api/MicCheck.Api/ApiKeys/ApiKeyHasher.cs
Executable file
@@ -0,0 +1,22 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public static class ApiKeyHasher
|
||||
{
|
||||
public static string Hash(string key)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string GenerateKey()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
9
src/api/MicCheck.Api/ApiKeys/ApiKeyResponse.cs
Executable file
9
src/api/MicCheck.Api/ApiKeys/ApiKeyResponse.cs
Executable file
@@ -0,0 +1,9 @@
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public record ApiKeyResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Prefix,
|
||||
bool IsActive,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset CreatedAt);
|
||||
50
src/api/MicCheck.Api/ApiKeys/ApiKeyService.cs
Executable file
50
src/api/MicCheck.Api/ApiKeys/ApiKeyService.cs
Executable file
@@ -0,0 +1,50 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public class ApiKeyService(MicCheckDbContext db)
|
||||
{
|
||||
public async Task<(ApiKey Key, string RawKey)> CreateAsync(
|
||||
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
|
||||
{
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
var hashedKey = ApiKeyHasher.Hash(rawKey);
|
||||
var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey;
|
||||
|
||||
var apiKey = new ApiKey
|
||||
{
|
||||
Key = hashedKey,
|
||||
Prefix = prefix,
|
||||
Name = name,
|
||||
OrganizationId = organizationId,
|
||||
IsActive = true,
|
||||
ExpiresAt = expiresAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.ApiKeys.Add(apiKey);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return (apiKey, rawKey);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.ApiKeys
|
||||
.Where(k => k.OrganizationId == organizationId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(int organizationId, int keyId, CancellationToken ct = default)
|
||||
{
|
||||
var key = await db.ApiKeys
|
||||
.FirstOrDefaultAsync(k => k.Id == keyId && k.OrganizationId == organizationId, ct);
|
||||
|
||||
if (key is null)
|
||||
return;
|
||||
|
||||
key.IsActive = false;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
3
src/api/MicCheck.Api/ApiKeys/CreateApiKeyRequest.cs
Executable file
3
src/api/MicCheck.Api/ApiKeys/CreateApiKeyRequest.cs
Executable file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public record CreateApiKeyRequest(string Name, DateTimeOffset? ExpiresAt);
|
||||
8
src/api/MicCheck.Api/ApiKeys/CreateApiKeyResponse.cs
Executable file
8
src/api/MicCheck.Api/ApiKeys/CreateApiKeyResponse.cs
Executable file
@@ -0,0 +1,8 @@
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public record CreateApiKeyResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Key,
|
||||
string Prefix,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
Reference in New Issue
Block a user