diff --git a/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyEntity.java b/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyEntity.java new file mode 100644 index 0000000..2fb08cb --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyEntity.java @@ -0,0 +1,51 @@ +package net.siegeln.cameleer.saas.apikey; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "api_keys") +public class ApiKeyEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "environment_id", nullable = false) + private UUID environmentId; + + @Column(name = "key_hash", nullable = false, length = 64) + private String keyHash; + + @Column(name = "key_prefix", nullable = false, length = 12) + private String keyPrefix; + + @Column(name = "status", nullable = false, length = 20) + private String status = "ACTIVE"; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "revoked_at") + private Instant revokedAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) createdAt = Instant.now(); + } + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + public UUID getEnvironmentId() { return environmentId; } + public void setEnvironmentId(UUID environmentId) { this.environmentId = environmentId; } + public String getKeyHash() { return keyHash; } + public void setKeyHash(String keyHash) { this.keyHash = keyHash; } + public String getKeyPrefix() { return keyPrefix; } + public void setKeyPrefix(String keyPrefix) { this.keyPrefix = keyPrefix; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public Instant getCreatedAt() { return createdAt; } + public Instant getRevokedAt() { return revokedAt; } + public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyRepository.java b/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyRepository.java new file mode 100644 index 0000000..f6f9fd7 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyRepository.java @@ -0,0 +1,12 @@ +package net.siegeln.cameleer.saas.apikey; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ApiKeyRepository extends JpaRepository { + Optional findByKeyHashAndStatus(String keyHash, String status); + List findByEnvironmentId(UUID environmentId); + List findByEnvironmentIdAndStatus(UUID environmentId, String status); +} diff --git a/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java b/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java new file mode 100644 index 0000000..8a82f2d --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java @@ -0,0 +1,84 @@ +package net.siegeln.cameleer.saas.apikey; + +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class ApiKeyService { + + public record GeneratedKey(String plaintext, String keyHash, String prefix) {} + + private final ApiKeyRepository repository; + + public ApiKeyService(ApiKeyRepository repository) { + this.repository = repository; + } + + public GeneratedKey generate() { + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + String plaintext = "cmk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + String hash = sha256Hex(plaintext); + String prefix = plaintext.substring(0, 12); + return new GeneratedKey(plaintext, hash, prefix); + } + + public GeneratedKey createForEnvironment(UUID environmentId) { + var key = generate(); + var entity = new ApiKeyEntity(); + entity.setEnvironmentId(environmentId); + entity.setKeyHash(key.keyHash()); + entity.setKeyPrefix(key.prefix()); + repository.save(entity); + return key; + } + + public Optional validate(String plaintext) { + String hash = sha256Hex(plaintext); + return repository.findByKeyHashAndStatus(hash, "ACTIVE"); + } + + public GeneratedKey rotate(UUID environmentId) { + List active = repository.findByEnvironmentIdAndStatus(environmentId, "ACTIVE"); + for (var k : active) { + k.setStatus("ROTATED"); + } + repository.saveAll(active); + return createForEnvironment(environmentId); + } + + public void revoke(UUID keyId) { + repository.findById(keyId).ifPresent(k -> { + k.setStatus("REVOKED"); + k.setRevokedAt(Instant.now()); + repository.save(k); + }); + } + + public List listByEnvironment(UUID environmentId) { + return repository.findByEnvironmentId(environmentId); + } + + public static String sha256Hex(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(64); + for (byte b : hash) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/apikey/ApiKeyServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/apikey/ApiKeyServiceTest.java new file mode 100644 index 0000000..108d7f2 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/apikey/ApiKeyServiceTest.java @@ -0,0 +1,34 @@ +package net.siegeln.cameleer.saas.apikey; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ApiKeyServiceTest { + + @Test + void generatedKeyShouldHaveCmkPrefix() { + var service = new ApiKeyService(null); + var key = service.generate(); + assertThat(key.plaintext()).startsWith("cmk_"); + assertThat(key.prefix()).hasSize(12); + assertThat(key.keyHash()).hasSize(64); + } + + @Test + void generatedKeyHashShouldBeConsistent() { + var service = new ApiKeyService(null); + var key = service.generate(); + String rehash = ApiKeyService.sha256Hex(key.plaintext()); + assertThat(rehash).isEqualTo(key.keyHash()); + } + + @Test + void twoGeneratedKeysShouldDiffer() { + var service = new ApiKeyService(null); + var key1 = service.generate(); + var key2 = service.generate(); + assertThat(key1.plaintext()).isNotEqualTo(key2.plaintext()); + assertThat(key1.keyHash()).isNotEqualTo(key2.keyHash()); + } +}