feat(sprint-3): Phase 1 — staff permissions + token revocation
- StaffPermission enum (8 granular permissions) - StaffAccount JPA entity with permissions collection - RevokedToken entity for JWT blacklisting - Flyway V3 migration (staff_accounts, staff_account_permissions, revoked_tokens) - StaffAccountRepository + RevokedTokenRepository - TokenRevocationService with Caffeine cache (60s TTL, 10k max) - StaffPermissionChecker SpEL bean (@staffPermissions.has) - PreventionOfficerChecker SpEL bean (@preventionOfficer.check) - JwtService: added jti claim + generateStaffAccessToken + extractJti/extractPermissions - JwtAuthFilter: token blacklist check via TokenRevocationService - SecurityConfig: STAFF role added to endpoint matchers - Controllers updated with @PreAuthorize for fine-grained access - TokenCleanupScheduler (daily 03:00 cleanup of expired revoked tokens) - Caffeine dependency added to cannamanage-service - Unit tests: StaffPermissionCheckerTest (7), TokenRevocationServiceTest (9)
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Scheduled task to clean up expired revoked tokens.
|
||||
* Runs daily at 03:00 to remove tokens whose expiration has passed
|
||||
* (they can no longer be used anyway, so the revocation record is stale).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TokenCleanupScheduler {
|
||||
|
||||
private final TokenRevocationService tokenRevocationService;
|
||||
|
||||
@Scheduled(cron = "0 0 3 * * *")
|
||||
public void cleanupExpiredTokens() {
|
||||
log.info("Starting expired token cleanup...");
|
||||
int deleted = tokenRevocationService.cleanupExpiredTokens();
|
||||
log.info("Expired token cleanup complete: {} tokens removed", deleted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import de.cannamanage.domain.entity.RevokedToken;
|
||||
import de.cannamanage.service.repository.RevokedTokenRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Service for JWT token revocation with Caffeine cache for fast lookups.
|
||||
* Cache: 60s TTL, max 10,000 entries.
|
||||
* Flow: isRevoked() checks cache first, then falls back to DB.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class TokenRevocationService {
|
||||
|
||||
private final RevokedTokenRepository revokedTokenRepository;
|
||||
|
||||
/**
|
||||
* Cache stores JTI → Boolean (true = revoked).
|
||||
* TTL 60s means a revoked token could still be accepted for up to 60s
|
||||
* on other nodes (acceptable tradeoff for single-node MVP).
|
||||
*/
|
||||
private final Cache<String, Boolean> revokedCache = Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.expireAfterWrite(60, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
public TokenRevocationService(RevokedTokenRepository revokedTokenRepository) {
|
||||
this.revokedTokenRepository = revokedTokenRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a token (by JTI) is revoked.
|
||||
* Checks local cache first, then DB as fallback.
|
||||
*/
|
||||
public boolean isRevoked(String jti) {
|
||||
if (jti == null || jti.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
Boolean cached = revokedCache.getIfPresent(jti);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fallback to DB
|
||||
boolean revoked = revokedTokenRepository.existsByJti(jti);
|
||||
if (revoked) {
|
||||
revokedCache.put(jti, true);
|
||||
}
|
||||
return revoked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes a single token by JTI.
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeToken(String jti, UUID userId, UUID tenantId, Instant expiresAt, String reason) {
|
||||
if (revokedTokenRepository.existsByJti(jti)) {
|
||||
log.debug("Token {} already revoked, skipping", jti);
|
||||
return;
|
||||
}
|
||||
|
||||
RevokedToken revokedToken = new RevokedToken();
|
||||
revokedToken.setJti(jti);
|
||||
revokedToken.setUserId(userId);
|
||||
revokedToken.setTenantId(tenantId);
|
||||
revokedToken.setRevokedAt(Instant.now());
|
||||
revokedToken.setExpiresAt(expiresAt);
|
||||
revokedToken.setReason(reason);
|
||||
|
||||
revokedTokenRepository.save(revokedToken);
|
||||
revokedCache.put(jti, true);
|
||||
log.info("Revoked token {} for user {} (reason: {})", jti, userId, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes expired revoked tokens from the database.
|
||||
* Called by TokenCleanupScheduler nightly.
|
||||
*/
|
||||
@Transactional
|
||||
public int cleanupExpiredTokens() {
|
||||
int deleted = revokedTokenRepository.deleteExpiredTokens(Instant.now());
|
||||
if (deleted > 0) {
|
||||
log.info("Cleaned up {} expired revoked tokens", deleted);
|
||||
revokedCache.invalidateAll();
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.RevokedToken;
|
||||
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 java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface RevokedTokenRepository extends JpaRepository<RevokedToken, UUID> {
|
||||
|
||||
boolean existsByJti(String jti);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM RevokedToken r WHERE r.expiresAt < :cutoff")
|
||||
int deleteExpiredTokens(@Param("cutoff") Instant cutoff);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM RevokedToken r WHERE r.userId = :userId")
|
||||
int deleteByUserId(@Param("userId") UUID userId);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface StaffAccountRepository extends JpaRepository<StaffAccount, UUID> {
|
||||
|
||||
Optional<StaffAccount> findByUserId(UUID userId);
|
||||
|
||||
List<StaffAccount> findByTenantIdAndActiveTrue(UUID tenantId);
|
||||
|
||||
List<StaffAccount> findByTenantIdAndPreventionOfficerTrue(UUID tenantId);
|
||||
|
||||
long countByTenantIdAndPreventionOfficerTrueAndActiveTrue(UUID tenantId);
|
||||
|
||||
boolean existsByUserId(UUID userId);
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.RevokedToken;
|
||||
import de.cannamanage.service.repository.RevokedTokenRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TokenRevocationServiceTest {
|
||||
|
||||
@Mock
|
||||
private RevokedTokenRepository revokedTokenRepository;
|
||||
|
||||
@InjectMocks
|
||||
private TokenRevocationService service;
|
||||
|
||||
private String testJti;
|
||||
private UUID testUserId;
|
||||
private UUID testTenantId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testJti = UUID.randomUUID().toString();
|
||||
testUserId = UUID.randomUUID();
|
||||
testTenantId = UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_notRevoked_returnsFalse() {
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(false);
|
||||
|
||||
assertThat(service.isRevoked(testJti)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_revoked_returnsTrue() {
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(true);
|
||||
|
||||
assertThat(service.isRevoked(testJti)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_nullJti_returnsFalse() {
|
||||
assertThat(service.isRevoked(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_blankJti_returnsFalse() {
|
||||
assertThat(service.isRevoked(" ")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_usesCache_onSecondCall() {
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(true);
|
||||
|
||||
// First call goes to DB
|
||||
assertThat(service.isRevoked(testJti)).isTrue();
|
||||
// Second call should use cache
|
||||
assertThat(service.isRevoked(testJti)).isTrue();
|
||||
|
||||
// DB should only be called once (cache handles second call)
|
||||
verify(revokedTokenRepository, times(1)).existsByJti(testJti);
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeToken_savesRevocation() {
|
||||
Instant expiresAt = Instant.now().plusSeconds(3600);
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(false);
|
||||
|
||||
service.revokeToken(testJti, testUserId, testTenantId, expiresAt, "logout");
|
||||
|
||||
ArgumentCaptor<RevokedToken> captor = ArgumentCaptor.forClass(RevokedToken.class);
|
||||
verify(revokedTokenRepository).save(captor.capture());
|
||||
|
||||
RevokedToken saved = captor.getValue();
|
||||
assertThat(saved.getJti()).isEqualTo(testJti);
|
||||
assertThat(saved.getUserId()).isEqualTo(testUserId);
|
||||
assertThat(saved.getTenantId()).isEqualTo(testTenantId);
|
||||
assertThat(saved.getExpiresAt()).isEqualTo(expiresAt);
|
||||
assertThat(saved.getReason()).isEqualTo("logout");
|
||||
assertThat(saved.getRevokedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeToken_alreadyRevoked_doesNotSaveAgain() {
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(true);
|
||||
|
||||
service.revokeToken(testJti, testUserId, testTenantId, Instant.now().plusSeconds(3600), "duplicate");
|
||||
|
||||
verify(revokedTokenRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupExpiredTokens_deletesExpired() {
|
||||
when(revokedTokenRepository.deleteExpiredTokens(any(Instant.class))).thenReturn(5);
|
||||
|
||||
int deleted = service.cleanupExpiredTokens();
|
||||
|
||||
assertThat(deleted).isEqualTo(5);
|
||||
verify(revokedTokenRepository).deleteExpiredTokens(any(Instant.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupExpiredTokens_nothingToDelete_returnsZero() {
|
||||
when(revokedTokenRepository.deleteExpiredTokens(any(Instant.class))).thenReturn(0);
|
||||
|
||||
int deleted = service.cleanupExpiredTokens();
|
||||
|
||||
assertThat(deleted).isEqualTo(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user