This commit is contained in:
2026-06-10 13:06:55 -07:00
parent d1a41d84d7
commit b871e0da32
987 changed files with 22413 additions and 21031 deletions

View 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; }
}

View 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");
}
}

View 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('/', '_');
}
}

View File

@@ -0,0 +1,9 @@
namespace MicCheck.Api.ApiKeys;
public record ApiKeyResponse(
int Id,
string Name,
string Prefix,
bool IsActive,
DateTimeOffset? ExpiresAt,
DateTimeOffset CreatedAt);

View 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);
}
}

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.ApiKeys;
public record CreateApiKeyRequest(string Name, DateTimeOffset? ExpiresAt);

View File

@@ -0,0 +1,8 @@
namespace MicCheck.Api.ApiKeys;
public record CreateApiKeyResponse(
int Id,
string Name,
string Key,
string Prefix,
DateTimeOffset? ExpiresAt);