feat: add API key entity, repository, and service with SHA-256 hashing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-05 12:39:50 +02:00
parent b8b0c686e8
commit 4b5a1cf2a2
4 changed files with 181 additions and 0 deletions

View File

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

View File

@@ -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<ApiKeyEntity, UUID> {
Optional<ApiKeyEntity> findByKeyHashAndStatus(String keyHash, String status);
List<ApiKeyEntity> findByEnvironmentId(UUID environmentId);
List<ApiKeyEntity> findByEnvironmentIdAndStatus(UUID environmentId, String status);
}

View File

@@ -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<ApiKeyEntity> validate(String plaintext) {
String hash = sha256Hex(plaintext);
return repository.findByKeyHashAndStatus(hash, "ACTIVE");
}
public GeneratedKey rotate(UUID environmentId) {
List<ApiKeyEntity> 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<ApiKeyEntity> 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);
}
}
}

View File

@@ -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());
}
}