Fix JWT display on account page

This commit is contained in:
Dario Ghunney Ware 2025-07-29 14:14:31 +01:00
parent 5ca688fc2e
commit 72d0339588
11 changed files with 460 additions and 109 deletions

View File

@ -24,6 +24,7 @@ public class InstallationPathConfig {
private static final String STATIC_PATH; private static final String STATIC_PATH;
private static final String TEMPLATES_PATH; private static final String TEMPLATES_PATH;
private static final String SIGNATURES_PATH; private static final String SIGNATURES_PATH;
private static final String PRIVATE_KEY_PATH;
static { static {
BASE_PATH = initializeBasePath(); BASE_PATH = initializeBasePath();
@ -43,6 +44,7 @@ public class InstallationPathConfig {
STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator; STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator;
TEMPLATES_PATH = CUSTOM_FILES_PATH + "templates" + File.separator; TEMPLATES_PATH = CUSTOM_FILES_PATH + "templates" + File.separator;
SIGNATURES_PATH = CUSTOM_FILES_PATH + "signatures" + File.separator; SIGNATURES_PATH = CUSTOM_FILES_PATH + "signatures" + File.separator;
PRIVATE_KEY_PATH = CUSTOM_FILES_PATH + "keys" + File.separator;
} }
private static String initializeBasePath() { private static String initializeBasePath() {
@ -114,4 +116,8 @@ public class InstallationPathConfig {
public static String getSignaturesPath() { public static String getSignaturesPath() {
return SIGNATURES_PATH; return SIGNATURES_PATH;
} }
public static String getPrivateKeyPath() {
return PRIVATE_KEY_PATH;
}
} }

View File

@ -303,6 +303,9 @@ public class ApplicationProperties {
public static class Jwt { public static class Jwt {
private boolean enableKeystore = true; private boolean enableKeystore = true;
private boolean enableKeyRotation = false; private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
private int keyRetentionDays = 7;
private int cleanupBatchSize = 100;
} }
} }

View File

@ -390,8 +390,13 @@
key.includes('clientSubmissionOrder') || key.includes('clientSubmissionOrder') ||
key.includes('lastSubmitTime') || key.includes('lastSubmitTime') ||
key.includes('lastClientId') || key.includes('lastClientId') ||
key.includes('stirling_jwt') ||
key.includes('JSESSIONID') ||
key.includes('XSRF-TOKEN') ||
key.includes('remember-me') ||
key.includes('auth') ||
key.includes('token') ||
key.includes('session') ||
key.includes('posthog') || key.includes('ssoRedirectAttempts') || key.includes('lastRedirectAttempt') || key.includes('surveyVersion') || key.includes('posthog') || key.includes('ssoRedirectAttempts') || key.includes('lastRedirectAttempt') || key.includes('surveyVersion') ||
key.includes('pageViews'); key.includes('pageViews');
} }

View File

@ -250,6 +250,8 @@ public class SecurityConfiguration {
|| trimmedUri.startsWith("/css/") || trimmedUri.startsWith("/css/")
|| trimmedUri.startsWith("/fonts/") || trimmedUri.startsWith("/fonts/")
|| trimmedUri.startsWith("/js/") || trimmedUri.startsWith("/js/")
|| trimmedUri.startsWith("/pdfjs/")
|| trimmedUri.startsWith("/pdfjs-legacy/")
|| trimmedUri.startsWith("/favicon") || trimmedUri.startsWith("/favicon")
|| trimmedUri.startsWith( || trimmedUri.startsWith(
"/api/v1/info/status") "/api/v1/info/status")

View File

@ -1,8 +1,14 @@
package stirling.software.proprietary.security.database.repository; package stirling.software.proprietary.security.database.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import stirling.software.proprietary.security.model.JwtSigningKey; import stirling.software.proprietary.security.model.JwtSigningKey;
@ -14,5 +20,16 @@ public interface JwtSigningKeyRepository extends JpaRepository<JwtSigningKey, Lo
Optional<JwtSigningKey> findByKeyId(String keyId); Optional<JwtSigningKey> findByKeyId(String keyId);
Optional<JwtSigningKey> findByKeyIdAndIsActiveTrue(String keyId); @Query(
"SELECT k FROM signing_keys k WHERE k.isActive = false AND k.createdAt < :cutoffDate ORDER BY k.createdAt ASC")
List<JwtSigningKey> findInactiveKeysOlderThan(
@Param("cutoffDate") LocalDateTime cutoffDate, Pageable pageable);
@Query(
"SELECT COUNT(k) FROM signing_keys k WHERE k.isActive = false AND k.createdAt < :cutoffDate")
long countKeysEligibleForCleanup(@Param("cutoffDate") LocalDateTime cutoffDate);
@Modifying
@Query("DELETE FROM signing_keys k WHERE k.id IN :ids")
void deleteAllByIdInBatch(@Param("ids") List<Long> ids);
} }

View File

@ -124,11 +124,10 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
response.getWriter() response.getWriter()
.write( .write(
""" """
Authentication required. Please provide a X-API-KEY in request\ Authentication required. Please provide a X-API-KEY in request header.
header.
This is found in Settings -> Account Settings -> API Key This is found in Settings -> Account Settings -> API Key
Alternatively you can disable authentication if this is\ Alternatively you can disable authentication if this is unexpected.
unexpected"""); """);
} }
return; return;
} }

View File

@ -0,0 +1,133 @@
package stirling.software.proprietary.security.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.JwtSigningKeyRepository;
import stirling.software.proprietary.security.model.JwtSigningKey;
@Slf4j
@Service
public class JwtKeyCleanupService {
private final JwtSigningKeyRepository signingKeyRepository;
private final JwtKeystoreService keystoreService;
private final ApplicationProperties.Security.Jwt jwtProperties;
@Autowired
public JwtKeyCleanupService(
JwtSigningKeyRepository signingKeyRepository,
JwtKeystoreService keystoreService,
ApplicationProperties applicationProperties) {
this.signingKeyRepository = signingKeyRepository;
this.keystoreService = keystoreService;
this.jwtProperties = applicationProperties.getSecurity().getJwt();
}
@Transactional
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.MINUTES)
public void cleanup() {
if (!jwtProperties.isEnableKeyCleanup() || !keystoreService.isKeystoreEnabled()) {
log.debug("Key cleanup is disabled, skipping cleanup");
return;
}
log.info("Removing inactive keys older than {} days", jwtProperties.getKeyRetentionDays());
try {
LocalDateTime cutoffDate =
LocalDateTime.now().minusDays(jwtProperties.getKeyRetentionDays());
long totalKeysEligible = signingKeyRepository.countKeysEligibleForCleanup(cutoffDate);
if (totalKeysEligible == 0) {
log.info("No keys eligible for cleanup");
return;
}
log.info("{} eligible keys found", totalKeysEligible);
batchCleanup(cutoffDate);
} catch (Exception e) {
log.error("Error during scheduled key cleanup", e);
}
}
private void batchCleanup(LocalDateTime cutoffDate) {
int batchSize = jwtProperties.getCleanupBatchSize();
while (true) {
Pageable pageable = PageRequest.of(0, batchSize);
List<JwtSigningKey> keysToCleanup =
signingKeyRepository.findInactiveKeysOlderThan(cutoffDate, pageable);
if (keysToCleanup.isEmpty()) {
break;
}
cleanupKeyBatch(keysToCleanup);
if (keysToCleanup.size() < batchSize) {
break;
}
}
}
private void cleanupKeyBatch(List<JwtSigningKey> keys) {
keys.forEach(
key -> {
try {
removePrivateKey(key.getKeyId());
} catch (IOException e) {
log.warn("Failed to cleanup private key for keyId: {}", key.getKeyId(), e);
}
});
List<Long> keyIds = keys.stream().map(JwtSigningKey::getId).collect(Collectors.toList());
signingKeyRepository.deleteAllByIdInBatch(keyIds);
log.debug("Deleted {} signing keys from database", keyIds.size());
}
private void removePrivateKey(String keyId) throws IOException {
if (!keystoreService.isKeystoreEnabled()) {
return;
}
Path privateKeyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
Path keyFile = privateKeyDirectory.resolve(keyId + JwtKeystoreService.KEY_SUFFIX);
if (Files.exists(keyFile)) {
Files.delete(keyFile);
log.debug("Deleted private key file: {}", keyFile);
} else {
log.debug("Private key file not found: {}", keyFile);
}
}
public long getKeysEligibleForCleanup() {
if (!jwtProperties.isEnableKeyCleanup() || !keystoreService.isKeystoreEnabled()) {
return 0;
}
LocalDateTime cutoffDate =
LocalDateTime.now().minusDays(jwtProperties.getKeyRetentionDays());
return signingKeyRepository.countKeysEligibleForCleanup(cutoffDate);
}
}

View File

@ -20,7 +20,6 @@ import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@ -31,14 +30,13 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.JwtSigningKeyRepository; import stirling.software.proprietary.security.database.repository.JwtSigningKeyRepository;
import stirling.software.proprietary.security.model.JwtSigningKey; import stirling.software.proprietary.security.model.JwtSigningKey;
@Service
@Slf4j @Slf4j
@Service
public class JwtKeystoreService implements JwtKeystoreServiceInterface { public class JwtKeystoreService implements JwtKeystoreServiceInterface {
public static final String KEY_SUFFIX = ".key"; public static final String KEY_SUFFIX = ".key";
private final JwtSigningKeyRepository repository; private final JwtSigningKeyRepository repository;
private final ApplicationProperties.Security.Jwt jwtProperties; private final ApplicationProperties.Security.Jwt jwtProperties;
private final Path privateKeyDirectory;
private volatile KeyPair currentKeyPair; private volatile KeyPair currentKeyPair;
private volatile String currentKeyId; private volatile String currentKeyId;
@ -48,13 +46,12 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface {
JwtSigningKeyRepository repository, ApplicationProperties applicationProperties) { JwtSigningKeyRepository repository, ApplicationProperties applicationProperties) {
this.repository = repository; this.repository = repository;
this.jwtProperties = applicationProperties.getSecurity().getJwt(); this.jwtProperties = applicationProperties.getSecurity().getJwt();
this.privateKeyDirectory = Paths.get(InstallationPathConfig.getConfigPath(), "jwt-keys");
} }
@PostConstruct @PostConstruct
public void initializeKeystore() { public void initializeKeystore() {
if (!isKeystoreEnabled()) { if (!isKeystoreEnabled()) {
log.info("JWT keystore is disabled, using in-memory key generation"); log.info("Keystore is disabled, using in-memory key generation");
return; return;
} }
@ -62,7 +59,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface {
ensurePrivateKeyDirectoryExists(); ensurePrivateKeyDirectoryExists();
loadOrGenerateKeypair(); loadOrGenerateKeypair();
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to initialize JWT keystore, falling back to in-memory generation", e); log.error("Failed to initialize keystore, falling back to in-memory generation", e);
} }
} }
@ -101,31 +98,6 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface {
return currentKeyId; return currentKeyId;
} }
@Override
@Transactional
public void rotateKeypair() {
if (!isKeystoreEnabled()) {
log.warn("Cannot rotate keypair when keystore is disabled");
return;
}
try {
repository
.findByIsActiveTrue()
.ifPresent(
key -> {
key.setIsActive(false);
repository.save(key);
});
generateAndStoreKeypair();
log.info("Successfully rotated JWT keypair");
} catch (Exception e) {
log.error("Failed to rotate JWT keypair", e);
throw new RuntimeException("Keypair rotation failed", e);
}
}
@Override @Override
public boolean isKeystoreEnabled() { public boolean isKeystoreEnabled() {
return jwtProperties.isEnableKeystore(); return jwtProperties.isEnableKeystore();
@ -140,7 +112,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface {
PrivateKey privateKey = loadPrivateKey(currentKeyId); PrivateKey privateKey = loadPrivateKey(currentKeyId);
PublicKey publicKey = decodePublicKey(activeKey.get().getSigningKey()); PublicKey publicKey = decodePublicKey(activeKey.get().getSigningKey());
currentKeyPair = new KeyPair(publicKey, privateKey); currentKeyPair = new KeyPair(publicKey, privateKey);
log.info("Loaded existing JWT keypair with keyId: {}", currentKeyId); log.info("Loaded existing keypair with keyId: {}", currentKeyId);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to load existing keypair, generating new one", e); log.error("Failed to load existing keypair, generating new one", e);
generateAndStoreKeypair(); generateAndStoreKeypair();
@ -163,7 +135,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface {
currentKeyPair = keyPair; currentKeyPair = keyPair;
currentKeyId = keyId; currentKeyId = keyId;
log.info("Generated and stored new JWT keypair with keyId: {}", keyId); log.info("Generated and stored new keypair with keyId: {}", keyId);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to generate and store keypair", e); log.error("Failed to generate and store keypair", e);
throw new RuntimeException("Keypair generation failed", e); throw new RuntimeException("Keypair generation failed", e);
@ -189,14 +161,16 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface {
} }
private void ensurePrivateKeyDirectoryExists() throws IOException { private void ensurePrivateKeyDirectoryExists() throws IOException {
if (!Files.exists(privateKeyDirectory)) { Path keyPath = Paths.get(InstallationPathConfig.getPrivateKeyPath());
Files.createDirectories(privateKeyDirectory);
log.info("Created JWT private key directory: {}", privateKeyDirectory); if (!Files.exists(keyPath)) {
Files.createDirectories(keyPath);
} }
} }
private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException { private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException {
Path keyFile = privateKeyDirectory.resolve(keyId + KEY_SUFFIX); Path keyFile =
Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX);
String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded()); String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded());
Files.writeString(keyFile, encodedKey); Files.writeString(keyFile, encodedKey);
@ -212,9 +186,11 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface {
private PrivateKey loadPrivateKey(String keyId) private PrivateKey loadPrivateKey(String keyId)
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
Path keyFile = privateKeyDirectory.resolve(keyId + KEY_SUFFIX); Path keyFile =
Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX);
if (!Files.exists(keyFile)) { if (!Files.exists(keyFile)) {
throw new IOException("Private key file not found: " + keyFile); throw new IOException("Private key not found: " + keyFile);
} }
String encodedKey = Files.readString(keyFile); String encodedKey = Files.readString(keyFile);

View File

@ -11,7 +11,5 @@ public interface JwtKeystoreServiceInterface {
String getActiveKeyId(); String getActiveKeyId();
void rotateKeypair();
boolean isKeystoreEnabled(); boolean isKeystoreEnabled();
} }

View File

@ -0,0 +1,248 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Pageable;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.JwtSigningKeyRepository;
import stirling.software.proprietary.security.model.JwtSigningKey;
@ExtendWith(MockitoExtension.class)
class JwtKeyCleanupServiceTest {
@Mock
private JwtSigningKeyRepository signingKeyRepository;
@Mock
private JwtKeystoreService keystoreService;
@Mock
private ApplicationProperties applicationProperties;
@Mock
private ApplicationProperties.Security security;
@Mock
private ApplicationProperties.Security.Jwt jwtConfig;
@TempDir
private Path tempDir;
private JwtKeyCleanupService cleanupService;
@BeforeEach
void setUp() {
lenient().when(applicationProperties.getSecurity()).thenReturn(security);
lenient().when(security.getJwt()).thenReturn(jwtConfig);
lenient().when(jwtConfig.isEnableKeyCleanup()).thenReturn(true);
lenient().when(jwtConfig.getKeyRetentionDays()).thenReturn(7);
lenient().when(jwtConfig.getCleanupBatchSize()).thenReturn(100);
lenient().when(keystoreService.isKeystoreEnabled()).thenReturn(true);
cleanupService = new JwtKeyCleanupService(signingKeyRepository, keystoreService, applicationProperties);
}
@Test
void testCleanupDisabled_ShouldSkip() {
when(jwtConfig.isEnableKeyCleanup()).thenReturn(false);
cleanupService.cleanup();
verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class));
verify(signingKeyRepository, never()).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class));
}
@Test
void testCleanup_WhenKeystoreDisabled_ShouldSkip() {
when(keystoreService.isKeystoreEnabled()).thenReturn(false);
cleanupService.cleanup();
verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class));
verify(signingKeyRepository, never()).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class));
}
@Test
void testCleanup_WhenNoKeysEligible_ShouldExitEarly() {
when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(0L);
cleanupService.cleanup();
verify(signingKeyRepository).countKeysEligibleForCleanup(any(LocalDateTime.class));
verify(signingKeyRepository, never()).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class));
}
@Test
void testCleanupSuccessfully() throws IOException {
JwtSigningKey key1 = createTestKey("key-1", 1L);
JwtSigningKey key2 = createTestKey("key-2", 2L);
List<JwtSigningKey> keysToCleanup = Arrays.asList(key1, key2);
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
createTestKeyFile("key-1");
createTestKeyFile("key-2");
when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(2L);
when(signingKeyRepository.findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)))
.thenReturn(keysToCleanup)
.thenReturn(Collections.emptyList());
cleanupService.cleanup();
verify(signingKeyRepository).countKeysEligibleForCleanup(any(LocalDateTime.class));
verify(signingKeyRepository).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class));
verify(signingKeyRepository).deleteAllByIdInBatch(Arrays.asList(1L, 2L));
assertFalse(Files.exists(tempDir.resolve("key-1.key")));
assertFalse(Files.exists(tempDir.resolve("key-2.key")));
}
}
@Test
void testCleanup_WithBatchProcessing_ShouldProcessMultipleBatches() throws IOException {
when(jwtConfig.getCleanupBatchSize()).thenReturn(2);
JwtSigningKey key1 = createTestKey("key-1", 1L);
JwtSigningKey key2 = createTestKey("key-2", 2L);
JwtSigningKey key3 = createTestKey("key-3", 3L);
List<JwtSigningKey> firstBatch = Arrays.asList(key1, key2);
List<JwtSigningKey> secondBatch = Arrays.asList(key3);
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
createTestKeyFile("key-1");
createTestKeyFile("key-2");
createTestKeyFile("key-3");
when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(3L);
when(signingKeyRepository.findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)))
.thenReturn(firstBatch)
.thenReturn(secondBatch)
.thenReturn(Collections.emptyList());
cleanupService.cleanup();
verify(signingKeyRepository, times(2)).deleteAllByIdInBatch(any());
verify(signingKeyRepository).deleteAllByIdInBatch(Arrays.asList(1L, 2L));
verify(signingKeyRepository).deleteAllByIdInBatch(Arrays.asList(3L));
}
}
@Test
void testCleanup() throws IOException {
JwtSigningKey key1 = createTestKey("key-1", 1L);
JwtSigningKey key2 = createTestKey("key-2", 2L);
List<JwtSigningKey> keysToCleanup = Arrays.asList(key1, key2);
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
createTestKeyFile("key-1");
when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(2L);
when(signingKeyRepository.findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)))
.thenReturn(keysToCleanup)
.thenReturn(Collections.emptyList());
cleanupService.cleanup();
verify(signingKeyRepository).deleteAllByIdInBatch(Arrays.asList(1L, 2L));
assertFalse(Files.exists(tempDir.resolve("key-1.key")));
}
}
@Test
void testGetKeysEligibleForCleanup() {
when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(5L);
long result = cleanupService.getKeysEligibleForCleanup();
assertEquals(5L, result);
verify(signingKeyRepository).countKeysEligibleForCleanup(any(LocalDateTime.class));
}
@Test
void shouldReturnZero_WhenCleanupDisabled() {
when(jwtConfig.isEnableKeyCleanup()).thenReturn(false);
long result = cleanupService.getKeysEligibleForCleanup();
assertEquals(0L, result);
verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class));
}
@Test
void shouldReturnZero_WhenKeystoreDisabled() {
when(keystoreService.isKeystoreEnabled()).thenReturn(false);
long result = cleanupService.getKeysEligibleForCleanup();
assertEquals(0L, result);
verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class));
}
@Test
void testCleanup_WithRetentionDaysConfiguration_ShouldUseCorrectCutoffDate() {
when(jwtConfig.getKeyRetentionDays()).thenReturn(14);
when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(0L);
cleanupService.cleanup();
verify(signingKeyRepository).countKeysEligibleForCleanup(argThat((LocalDateTime cutoffDate) -> {
LocalDateTime expectedCutoff = LocalDateTime.now().minusDays(14);
return Math.abs(java.time.Duration.between(cutoffDate, expectedCutoff).toMinutes()) <= 1;
}));
}
@Test
void testCleanupPrivateKeyFile_WhenKeystoreDisabled_ShouldSkipFileRemove() throws IOException {
when(keystoreService.isKeystoreEnabled()).thenReturn(false);
cleanupService.cleanup();
verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class));
verify(signingKeyRepository, never()).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class));
verify(signingKeyRepository, never()).deleteAllByIdInBatch(any());
}
private JwtSigningKey createTestKey(String keyId, Long id) {
JwtSigningKey key = new JwtSigningKey();
key.setId(id);
key.setKeyId(keyId);
key.setSigningKey("test-public-key");
key.setAlgorithm("RS256");
key.setIsActive(false);
key.setCreatedAt(LocalDateTime.now().minusDays(10));
return key;
}
private void createTestKeyFile(String keyId) throws IOException {
Path keyFile = tempDir.resolve(keyId + ".key");
Files.writeString(keyFile, "test-private-key-content");
}
}

View File

@ -1,9 +1,5 @@
package stirling.software.proprietary.security.service; package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@ -12,7 +8,6 @@ import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.Base64; import java.util.Base64;
import java.util.Optional; import java.util.Optional;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@ -22,11 +17,19 @@ import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.JwtSigningKeyRepository; import stirling.software.proprietary.security.database.repository.JwtSigningKeyRepository;
import stirling.software.proprietary.security.model.JwtSigningKey; import stirling.software.proprietary.security.model.JwtSigningKey;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class JwtKeystoreServiceInterfaceTest { class JwtKeystoreServiceInterfaceTest {
@ -55,9 +58,9 @@ class JwtKeystoreServiceInterfaceTest {
keyPairGenerator.initialize(2048); keyPairGenerator.initialize(2048);
testKeyPair = keyPairGenerator.generateKeyPair(); testKeyPair = keyPairGenerator.generateKeyPair();
when(applicationProperties.getSecurity()).thenReturn(security); lenient().when(applicationProperties.getSecurity()).thenReturn(security);
when(security.getJwt()).thenReturn(jwtConfig); lenient().when(security.getJwt()).thenReturn(jwtConfig);
when(jwtConfig.isEnableKeystore()).thenReturn(true); lenient().when(jwtConfig.isEnableKeystore()).thenReturn(true);
} }
@ParameterizedTest @ParameterizedTest
@ -66,7 +69,7 @@ class JwtKeystoreServiceInterfaceTest {
when(jwtConfig.isEnableKeystore()).thenReturn(keystoreEnabled); when(jwtConfig.isEnableKeystore()).thenReturn(keystoreEnabled);
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new JwtKeystoreService(repository, applicationProperties);
assertEquals(keystoreEnabled, keystoreService.isKeystoreEnabled()); assertEquals(keystoreEnabled, keystoreService.isKeystoreEnabled());
@ -78,7 +81,7 @@ class JwtKeystoreServiceInterfaceTest {
when(jwtConfig.isEnableKeystore()).thenReturn(false); when(jwtConfig.isEnableKeystore()).thenReturn(false);
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new JwtKeystoreService(repository, applicationProperties);
KeyPair result = keystoreService.getActiveKeypair(); KeyPair result = keystoreService.getActiveKeypair();
@ -94,7 +97,7 @@ class JwtKeystoreServiceInterfaceTest {
when(repository.findByIsActiveTrue()).thenReturn(Optional.empty()); when(repository.findByIsActiveTrue()).thenReturn(Optional.empty());
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new JwtKeystoreService(repository, applicationProperties);
keystoreService.initializeKeystore(); keystoreService.initializeKeystore();
@ -114,12 +117,11 @@ class JwtKeystoreServiceInterfaceTest {
JwtSigningKey existingKey = new JwtSigningKey(keyId, publicKeyBase64, "RS256"); JwtSigningKey existingKey = new JwtSigningKey(keyId, publicKeyBase64, "RS256");
when(repository.findByIsActiveTrue()).thenReturn(Optional.of(existingKey)); when(repository.findByIsActiveTrue()).thenReturn(Optional.of(existingKey));
Path keyFile = tempDir.resolve("jwt-keys").resolve(keyId + ".key"); Path keyFile = tempDir.resolve(keyId + ".key");
Files.createDirectories(keyFile.getParent());
Files.writeString(keyFile, privateKeyBase64); Files.writeString(keyFile, privateKeyBase64);
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new JwtKeystoreService(repository, applicationProperties);
keystoreService.initializeKeystore(); keystoreService.initializeKeystore();
@ -139,12 +141,11 @@ class JwtKeystoreServiceInterfaceTest {
JwtSigningKey signingKey = new JwtSigningKey(keyId, publicKeyBase64, "RS256"); JwtSigningKey signingKey = new JwtSigningKey(keyId, publicKeyBase64, "RS256");
when(repository.findByKeyId(keyId)).thenReturn(Optional.of(signingKey)); when(repository.findByKeyId(keyId)).thenReturn(Optional.of(signingKey));
Path keyFile = tempDir.resolve("jwt-keys").resolve(keyId + ".key"); Path keyFile = tempDir.resolve(keyId + ".key");
Files.createDirectories(keyFile.getParent());
Files.writeString(keyFile, privateKeyBase64); Files.writeString(keyFile, privateKeyBase64);
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new JwtKeystoreService(repository, applicationProperties);
Optional<KeyPair> result = keystoreService.getKeypairByKeyId(keyId); Optional<KeyPair> result = keystoreService.getKeypairByKeyId(keyId);
@ -161,7 +162,7 @@ class JwtKeystoreServiceInterfaceTest {
when(repository.findByKeyId(keyId)).thenReturn(Optional.empty()); when(repository.findByKeyId(keyId)).thenReturn(Optional.empty());
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new JwtKeystoreService(repository, applicationProperties);
Optional<KeyPair> result = keystoreService.getKeypairByKeyId(keyId); Optional<KeyPair> result = keystoreService.getKeypairByKeyId(keyId);
@ -175,7 +176,7 @@ class JwtKeystoreServiceInterfaceTest {
when(jwtConfig.isEnableKeystore()).thenReturn(false); when(jwtConfig.isEnableKeystore()).thenReturn(false);
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new JwtKeystoreService(repository, applicationProperties);
Optional<KeyPair> result = keystoreService.getKeypairByKeyId("any-key"); Optional<KeyPair> result = keystoreService.getKeypairByKeyId("any-key");
@ -184,54 +185,17 @@ class JwtKeystoreServiceInterfaceTest {
} }
} }
@Test
void testRotateKeypair() {
String oldKeyId = "old-key-123";
JwtSigningKey oldKey = new JwtSigningKey(oldKeyId, "old-public-key", "RS256");
when(repository.findByIsActiveTrue()).thenReturn(Optional.of(oldKey));
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties);
keystoreService.initializeKeystore();
keystoreService.rotateKeypair();
assertFalse(oldKey.getIsActive());
verify(repository, atLeast(2)).save(any(JwtSigningKey.class)); // At least one for deactivation, one for new key
assertNotNull(keystoreService.getActiveKeyId());
assertNotEquals(oldKeyId, keystoreService.getActiveKeyId());
}
}
@Test
void testRotateKeypairWhenKeystoreDisabled() {
when(jwtConfig.isEnableKeystore()).thenReturn(false);
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties);
assertDoesNotThrow(() -> keystoreService.rotateKeypair());
verify(repository, never()).save(any());
}
}
@Test @Test
void testInitializeKeystoreCreatesDirectory() throws IOException { void testInitializeKeystoreCreatesDirectory() throws IOException {
when(repository.findByIsActiveTrue()).thenReturn(Optional.empty()); when(repository.findByIsActiveTrue()).thenReturn(Optional.empty());
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new JwtKeystoreService(repository, applicationProperties);
keystoreService.initializeKeystore(); keystoreService.initializeKeystore();
Path jwtKeysDir = tempDir.resolve("jwt-keys"); assertTrue(Files.exists(tempDir));
assertTrue(Files.exists(jwtKeysDir)); assertTrue(Files.isDirectory(tempDir));
assertTrue(Files.isDirectory(jwtKeysDir));
} }
} }
@ -244,7 +208,7 @@ class JwtKeystoreServiceInterfaceTest {
when(repository.findByIsActiveTrue()).thenReturn(Optional.of(existingKey)); when(repository.findByIsActiveTrue()).thenReturn(Optional.of(existingKey));
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new JwtKeystoreService(repository, applicationProperties);
keystoreService.initializeKeystore(); keystoreService.initializeKeystore();