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:
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user