feat: Sprint 13 — Production Hardening (security fixes, CI gate, rate limiting, tests)
Deploy to TrueNAS / deploy (push) Failing after 12s
Deploy to TrueNAS / deploy (push) Failing after 12s
This commit is contained in:
@@ -140,6 +140,17 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<!-- Rate limiting (Bucket4j + Caffeine cache) -->
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-core</artifactId>
|
||||
<version>8.10.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
<version>3.1.8</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -21,6 +22,7 @@ import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF', 'MEMBER')")
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentService documentService;
|
||||
@@ -33,13 +35,14 @@ public class DocumentController {
|
||||
* Verify the requested document belongs to the caller's current tenant (club).
|
||||
* Prevents IDOR: a user from club A must not be able to download/delete a document of club B
|
||||
* just by guessing or enumerating the document UUID.
|
||||
* Returns 404 (not 403) to avoid revealing document existence to other tenants.
|
||||
*/
|
||||
private Document loadOwnedDocument(UUID documentId) {
|
||||
Document doc = documentService.getDocument(documentId);
|
||||
UUID currentTenantId = TenantContext.getCurrentTenant();
|
||||
if (currentTenantId == null || doc.getClubId() == null || !doc.getClubId().equals(currentTenantId)) {
|
||||
// Use 403 (not 404) — caller is authenticated, just not authorized for this resource.
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied to document");
|
||||
// Return 404 to prevent information leakage about document existence across tenants
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found");
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
@@ -78,6 +81,7 @@ public class DocumentController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/documents/{id}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
|
||||
public ResponseEntity<Void> deleteDocument(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam UUID clubId,
|
||||
@@ -87,7 +91,7 @@ public class DocumentController {
|
||||
Document doc = loadOwnedDocument(id);
|
||||
UUID currentTenantId = TenantContext.getCurrentTenant();
|
||||
if (!clubId.equals(currentTenantId)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Tenant mismatch");
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found");
|
||||
}
|
||||
UUID userId = UUID.fromString(principal.getName());
|
||||
documentService.deleteDocument(id, userId, doc.getClubId());
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import io.github.bucket4j.ConsumptionProbe;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Rate-limits login attempts per client IP using Bucket4j + Caffeine cache.
|
||||
* Allows 5 login attempts per minute per IP; returns 429 when exhausted.
|
||||
*/
|
||||
@Component
|
||||
@Order(1)
|
||||
public class LoginRateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String LOGIN_PATH = "/api/v1/auth/login";
|
||||
private static final int CAPACITY = 5;
|
||||
private static final Duration REFILL_PERIOD = Duration.ofMinutes(1);
|
||||
|
||||
private final Cache<String, Bucket> buckets = Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.expireAfterAccess(Duration.ofMinutes(10))
|
||||
.build();
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
if (!"POST".equalsIgnoreCase(request.getMethod()) || !LOGIN_PATH.equals(request.getRequestURI())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String clientIp = resolveClientIp(request);
|
||||
Bucket bucket = buckets.get(clientIp, k -> createBucket());
|
||||
|
||||
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
|
||||
if (probe.isConsumed()) {
|
||||
filterChain.doFilter(request, response);
|
||||
} else {
|
||||
long waitSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000 + 1;
|
||||
response.setStatus(429);
|
||||
response.setHeader("Retry-After", String.valueOf(waitSeconds));
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\":\"Too many login attempts. Retry after " + waitSeconds + "s\"}");
|
||||
}
|
||||
}
|
||||
|
||||
private Bucket createBucket() {
|
||||
return Bucket.builder()
|
||||
.addLimit(Bandwidth.builder()
|
||||
.capacity(CAPACITY)
|
||||
.refillGreedy(CAPACITY, REFILL_PERIOD)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (xff != null && !xff.isBlank()) {
|
||||
// Take the first IP in the chain (original client)
|
||||
return xff.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
@@ -71,10 +72,13 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
|
||||
// Documents endpoint — explicit listing for defense-in-depth so it can
|
||||
// never accidentally end up in a permitAll() rule above. Per-document
|
||||
// tenant ownership is additionally enforced in DocumentController.
|
||||
.requestMatchers("/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
// Documents endpoint — method-specific matchers for defense-in-depth.
|
||||
// POST (upload) and DELETE restricted to ADMIN/STAFF; GET allowed for all
|
||||
// authenticated roles. Per-document tenant ownership is additionally
|
||||
// enforced in DocumentController via TenantContext.
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ import java.util.UUID;
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private static final String INVALID_CREDENTIALS = "Invalid credentials";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final JwtService jwtService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
@@ -43,14 +45,14 @@ public class AuthService {
|
||||
@Transactional
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
User user = userRepository.findByEmail(request.email())
|
||||
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
|
||||
.orElseThrow(() -> new AuthenticationException(INVALID_CREDENTIALS));
|
||||
|
||||
if (!user.isActive()) {
|
||||
throw new AuthenticationException("Account not activated");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
|
||||
throw new AuthenticationException("Invalid credentials");
|
||||
throw new AuthenticationException(INVALID_CREDENTIALS);
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
@@ -147,7 +149,7 @@ public class AuthService {
|
||||
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException("SHA-256 not available", e);
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.Document;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.DocumentAccessLevel;
|
||||
import de.cannamanage.domain.enums.DocumentCategory;
|
||||
import de.cannamanage.service.DocumentService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Security unit tests for {@link DocumentController}.
|
||||
* Verifies tenant isolation (IDOR protection) at the controller layer.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentControllerSecurityTest {
|
||||
|
||||
@Mock
|
||||
private DocumentService documentService;
|
||||
|
||||
@InjectMocks
|
||||
private DocumentController documentController;
|
||||
|
||||
private static final UUID CLUB_A = UUID.fromString("00000000-0000-0000-0000-00000000000a");
|
||||
private static final UUID CLUB_B = UUID.fromString("00000000-0000-0000-0000-00000000000b");
|
||||
private static final UUID DOC_ID = UUID.fromString("00000000-0000-0000-0000-000000000099");
|
||||
private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Default tenant context: CLUB_A
|
||||
TenantContext.setCurrentTenant(CLUB_A);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
// --- T-09: Download wrong tenant → 404 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("downloadDocument — wrong tenant throws 404 (IDOR protection)")
|
||||
void testDownload_wrongTenant_returns404() {
|
||||
// Document belongs to CLUB_B but user's tenant is CLUB_A
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_B);
|
||||
doc.setFilename("secret.pdf");
|
||||
doc.setContentType("application/pdf");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
|
||||
assertThatThrownBy(() -> documentController.downloadDocument(DOC_ID))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(ex -> {
|
||||
ResponseStatusException rse = (ResponseStatusException) ex;
|
||||
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
});
|
||||
}
|
||||
|
||||
// --- T-10: Download correct tenant → 200 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("downloadDocument — correct tenant returns content")
|
||||
void testDownload_correctTenant_succeeds() throws IOException {
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_A);
|
||||
doc.setFilename("report.pdf");
|
||||
doc.setContentType("application/pdf");
|
||||
doc.setStoragePath(CLUB_A + "/" + DOC_ID + "_report.pdf");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
when(documentService.downloadDocument(DOC_ID)).thenReturn("test content".getBytes());
|
||||
|
||||
ResponseEntity<byte[]> response = documentController.downloadDocument(DOC_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
}
|
||||
|
||||
// --- T-11: Delete wrong tenant → 404 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteDocument — wrong tenant throws 404 (IDOR protection)")
|
||||
void testDelete_wrongTenant_returns404() {
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_B);
|
||||
doc.setTitle("Secret Doc");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
|
||||
Principal principal = mock(Principal.class);
|
||||
|
||||
assertThatThrownBy(() -> documentController.deleteDocument(DOC_ID, CLUB_A, principal))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(ex -> {
|
||||
ResponseStatusException rse = (ResponseStatusException) ex;
|
||||
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
});
|
||||
}
|
||||
|
||||
// --- T-12: Delete correct tenant → 204 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteDocument — correct tenant and matching clubId succeeds")
|
||||
void testDelete_correctTenant_succeeds() throws IOException {
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_A);
|
||||
doc.setTitle("My Doc");
|
||||
doc.setStoragePath(CLUB_A + "/" + DOC_ID + "_my.pdf");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
|
||||
Principal principal = mock(Principal.class);
|
||||
when(principal.getName()).thenReturn(USER_ID.toString());
|
||||
|
||||
ResponseEntity<Void> response = documentController.deleteDocument(DOC_ID, CLUB_A, principal);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
// --- T-13/T-14: Upload role restriction is handled by Spring Security @PreAuthorize,
|
||||
// not testable in a pure unit test. Covered by SecurityConfigIntegrationTest. ---
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteDocument — mismatched clubId param vs tenant throws 404")
|
||||
void testDelete_mismatchedClubIdParam_returns404() {
|
||||
// Document belongs to CLUB_A and tenant is CLUB_A, but clubId param is different
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_A);
|
||||
doc.setTitle("Doc");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
|
||||
Principal principal = mock(Principal.class);
|
||||
|
||||
// Passing CLUB_B as the clubId param while tenant is CLUB_A
|
||||
assertThatThrownBy(() -> documentController.deleteDocument(DOC_ID, CLUB_B, principal))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(ex -> {
|
||||
ResponseStatusException rse = (ResponseStatusException) ex;
|
||||
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
});
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link LoginRateLimitFilter} covering rate limiting with Bucket4j + Caffeine.
|
||||
* Tests per-IP bucket isolation, blocking after threshold, and Retry-After header.
|
||||
*/
|
||||
class LoginRateLimitFilterTest {
|
||||
|
||||
private LoginRateLimitFilter filter;
|
||||
private FilterChain filterChain;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
filter = new LoginRateLimitFilter();
|
||||
filterChain = mock(FilterChain.class);
|
||||
}
|
||||
|
||||
// --- T-26: First 5 requests pass ---
|
||||
|
||||
@Test
|
||||
@DisplayName("Rate limit — first 5 requests from same IP are allowed")
|
||||
void testRateLimit_allowsFirstFiveRequests() throws ServletException, IOException {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
MockHttpServletRequest request = createLoginRequest("192.168.1.1");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
|
||||
assertThat(response.getStatus()).isNotEqualTo(429);
|
||||
}
|
||||
|
||||
// FilterChain should have been invoked 5 times
|
||||
verify(filterChain, times(5)).doFilter(any(), any());
|
||||
}
|
||||
|
||||
// --- T-27: 6th request returns 429 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("Rate limit — 6th request from same IP returns 429")
|
||||
void testRateLimit_blocks6thRequest_returns429() throws ServletException, IOException {
|
||||
String ip = "10.0.0.1";
|
||||
|
||||
// Exhaust the 5-request bucket
|
||||
for (int i = 0; i < 5; i++) {
|
||||
MockHttpServletRequest request = createLoginRequest(ip);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
}
|
||||
|
||||
// 6th request should be rate-limited
|
||||
MockHttpServletRequest request = createLoginRequest(ip);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(429);
|
||||
assertThat(response.getContentAsString()).contains("Too many login attempts");
|
||||
}
|
||||
|
||||
// --- Retry-After header ---
|
||||
|
||||
@Test
|
||||
@DisplayName("Rate limit — 429 response includes Retry-After header")
|
||||
void testRateLimit_includesRetryAfterHeader() throws ServletException, IOException {
|
||||
String ip = "10.0.0.2";
|
||||
|
||||
// Exhaust the bucket
|
||||
for (int i = 0; i < 5; i++) {
|
||||
filter.doFilterInternal(createLoginRequest(ip), new MockHttpServletResponse(), filterChain);
|
||||
}
|
||||
|
||||
// 6th request — check headers
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilterInternal(createLoginRequest(ip), response, filterChain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(429);
|
||||
String retryAfter = response.getHeader("Retry-After");
|
||||
assertThat(retryAfter).isNotNull();
|
||||
assertThat(Integer.parseInt(retryAfter)).isGreaterThan(0);
|
||||
}
|
||||
|
||||
// --- T-28: Separate buckets per IP ---
|
||||
|
||||
@Test
|
||||
@DisplayName("Rate limit — different IPs have separate rate limit buckets")
|
||||
void testRateLimit_separateBucketsPerIp() throws ServletException, IOException {
|
||||
String ip1 = "192.168.1.100";
|
||||
String ip2 = "192.168.1.200";
|
||||
|
||||
// Exhaust quota for ip1
|
||||
for (int i = 0; i < 5; i++) {
|
||||
filter.doFilterInternal(createLoginRequest(ip1), new MockHttpServletResponse(), filterChain);
|
||||
}
|
||||
|
||||
// ip1 should be blocked
|
||||
MockHttpServletResponse responseIp1 = new MockHttpServletResponse();
|
||||
filter.doFilterInternal(createLoginRequest(ip1), responseIp1, filterChain);
|
||||
assertThat(responseIp1.getStatus()).isEqualTo(429);
|
||||
|
||||
// ip2 should still be allowed
|
||||
MockHttpServletResponse responseIp2 = new MockHttpServletResponse();
|
||||
filter.doFilterInternal(createLoginRequest(ip2), responseIp2, filterChain);
|
||||
assertThat(responseIp2.getStatus()).isNotEqualTo(429);
|
||||
}
|
||||
|
||||
// --- Non-login requests pass through ---
|
||||
|
||||
@Test
|
||||
@DisplayName("Non-login endpoint requests are not rate limited")
|
||||
void testNonLoginEndpoint_notRateLimited() throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/members");
|
||||
request.setRemoteAddr("10.0.0.5");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
|
||||
verify(filterChain).doFilter(request, response);
|
||||
assertThat(response.getStatus()).isNotEqualTo(429);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET request to login path is not rate limited")
|
||||
void testGetLoginPath_notRateLimited() throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/auth/login");
|
||||
request.setRemoteAddr("10.0.0.6");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
|
||||
verify(filterChain).doFilter(request, response);
|
||||
assertThat(response.getStatus()).isNotEqualTo(429);
|
||||
}
|
||||
|
||||
// --- X-Forwarded-For header ---
|
||||
|
||||
@Test
|
||||
@DisplayName("Rate limit uses X-Forwarded-For header for client IP resolution")
|
||||
void testRateLimit_usesXForwardedFor() throws ServletException, IOException {
|
||||
String realIp = "203.0.113.50";
|
||||
|
||||
// Exhaust bucket via X-Forwarded-For IP
|
||||
for (int i = 0; i < 5; i++) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/login");
|
||||
request.setRemoteAddr("127.0.0.1"); // proxy IP
|
||||
request.addHeader("X-Forwarded-For", realIp + ", 10.0.0.1");
|
||||
filter.doFilterInternal(request, new MockHttpServletResponse(), filterChain);
|
||||
}
|
||||
|
||||
// 6th request from same forwarded IP should be blocked
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/login");
|
||||
request.setRemoteAddr("127.0.0.1");
|
||||
request.addHeader("X-Forwarded-For", realIp + ", 10.0.0.1");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(429);
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
private MockHttpServletRequest createLoginRequest(String ip) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/login");
|
||||
request.setRemoteAddr(ip);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link SecurityConfig} — verifying that the security filter chain
|
||||
* correctly requires authentication for protected endpoints and allows public endpoints.
|
||||
* Uses RestClient against an actual HTTP server (same pattern as AuthControllerIntegrationTest).
|
||||
*
|
||||
* Note: The existing SecurityConfigIntegrationTest (Testcontainers) covers the same cases
|
||||
* with a full database. This test uses the simpler "test" profile for faster execution.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
class SecurityConfigTest {
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
private RestClient restClient() {
|
||||
return RestClient.builder()
|
||||
.baseUrl("http://localhost:" + port)
|
||||
.build();
|
||||
}
|
||||
|
||||
// --- T-21: Document endpoints require authentication ---
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/v1/documents — unauthenticated returns 401")
|
||||
void testDocumentEndpoints_requireAuthentication() {
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/api/v1/documents?clubId=00000000-0000-0000-0000-000000000001")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/v1/documents/{id}/download — unauthenticated returns 401")
|
||||
void testDocumentDownload_requiresAuthentication() {
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/api/v1/documents/00000000-0000-0000-0000-000000000099/download")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
// --- T-22: Auth endpoints are public ---
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/v1/auth/login — accessible without authentication (not 401)")
|
||||
void testAuthEndpoints_arePublic() {
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/auth/login")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.body("{\"email\":\"test@test.de\",\"password\":\"test\"}")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Auth endpoints are public — should NOT return 401/403
|
||||
// May return 400 or 500 (user not found), that's fine
|
||||
assertThat(response.getStatusCode().value()).isNotEqualTo(401);
|
||||
assertThat(response.getStatusCode().value()).isNotEqualTo(403);
|
||||
}
|
||||
|
||||
// --- T-23: Actuator health is public ---
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /actuator/health — accessible without authentication")
|
||||
void testActuatorHealth_isPublic() {
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/actuator/health")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package de.cannamanage.api.service;
|
||||
|
||||
import de.cannamanage.api.dto.auth.LoginRequest;
|
||||
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||
import de.cannamanage.api.dto.auth.RefreshRequest;
|
||||
import de.cannamanage.api.security.JwtService;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.UserRole;
|
||||
import de.cannamanage.service.repository.InviteTokenRepository;
|
||||
import de.cannamanage.service.repository.StaffAccountRepository;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link AuthService} covering login, token refresh, and SHA-256 hashing.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private JwtService jwtService;
|
||||
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Mock
|
||||
private InviteTokenRepository inviteTokenRepository;
|
||||
|
||||
@Mock
|
||||
private StaffAccountRepository staffAccountRepository;
|
||||
|
||||
@InjectMocks
|
||||
private AuthService authService;
|
||||
|
||||
private User activeUser;
|
||||
private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
private static final UUID TENANT_ID = UUID.fromString("00000000-0000-0000-0000-000000000010");
|
||||
private static final String EMAIL = "admin@test.de";
|
||||
private static final String PASSWORD = "SecurePass123!";
|
||||
private static final String HASHED_PASSWORD = "$2a$10$hashedvalue";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
activeUser = new User();
|
||||
activeUser.setId(USER_ID);
|
||||
activeUser.setEmail(EMAIL);
|
||||
activeUser.setPasswordHash(HASHED_PASSWORD);
|
||||
activeUser.setRole(UserRole.ROLE_ADMIN);
|
||||
activeUser.setActive(true);
|
||||
activeUser.setTenantId(TENANT_ID);
|
||||
}
|
||||
|
||||
// --- T-15: Login valid credentials → token pair ---
|
||||
|
||||
@Test
|
||||
@DisplayName("login — valid credentials returns token pair")
|
||||
void testLogin_validCredentials_returnsTokenPair() {
|
||||
when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(activeUser));
|
||||
when(passwordEncoder.matches(PASSWORD, HASHED_PASSWORD)).thenReturn(true);
|
||||
when(jwtService.generateAccessToken(any(), any(), anyString(), anyString()))
|
||||
.thenReturn("access-token-123");
|
||||
when(jwtService.generateRefreshToken(any(), any()))
|
||||
.thenReturn("refresh-token-456");
|
||||
when(userRepository.save(any(User.class))).thenReturn(activeUser);
|
||||
|
||||
LoginResponse response = authService.login(new LoginRequest(EMAIL, PASSWORD));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.accessToken()).isEqualTo("access-token-123");
|
||||
assertThat(response.refreshToken()).isEqualTo("refresh-token-456");
|
||||
assertThat(response.expiresIn()).isEqualTo(3600L);
|
||||
assertThat(response.role()).isEqualTo("ADMIN");
|
||||
}
|
||||
|
||||
// --- T-16: Login invalid password → 401 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("login — invalid password throws AuthenticationException")
|
||||
void testLogin_invalidPassword_throws401() {
|
||||
when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(activeUser));
|
||||
when(passwordEncoder.matches("wrong-password", HASHED_PASSWORD)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> authService.login(new LoginRequest(EMAIL, "wrong-password")))
|
||||
.isInstanceOf(AuthService.AuthenticationException.class)
|
||||
.hasMessageContaining("Invalid credentials");
|
||||
}
|
||||
|
||||
// --- T-17: Login non-existent user → 401 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("login — non-existent user throws AuthenticationException")
|
||||
void testLogin_nonExistentUser_throws401() {
|
||||
when(userRepository.findByEmail("nobody@test.de")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> authService.login(new LoginRequest("nobody@test.de", PASSWORD)))
|
||||
.isInstanceOf(AuthService.AuthenticationException.class)
|
||||
.hasMessageContaining("Invalid credentials");
|
||||
}
|
||||
|
||||
// --- T-18: Refresh token valid → new access token ---
|
||||
|
||||
@Test
|
||||
@DisplayName("refresh — valid token returns new access token")
|
||||
void testRefreshToken_validToken_returnsNewAccessToken() {
|
||||
String oldRefreshToken = "valid-refresh-token";
|
||||
// Compute expected hash
|
||||
String expectedHash = sha256(oldRefreshToken);
|
||||
activeUser.setRefreshTokenHash(expectedHash);
|
||||
|
||||
when(jwtService.isTokenValid(oldRefreshToken)).thenReturn(true);
|
||||
when(jwtService.extractUserId(oldRefreshToken)).thenReturn(USER_ID);
|
||||
when(userRepository.findById(USER_ID)).thenReturn(Optional.of(activeUser));
|
||||
when(jwtService.generateAccessToken(any(), any(), anyString(), anyString()))
|
||||
.thenReturn("new-access-token");
|
||||
when(jwtService.generateRefreshToken(any(), any()))
|
||||
.thenReturn("new-refresh-token");
|
||||
when(userRepository.save(any(User.class))).thenReturn(activeUser);
|
||||
|
||||
LoginResponse response = authService.refresh(new RefreshRequest(oldRefreshToken));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.accessToken()).isEqualTo("new-access-token");
|
||||
assertThat(response.refreshToken()).isEqualTo("new-refresh-token");
|
||||
}
|
||||
|
||||
// --- T-19: Refresh token expired → 401 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("refresh — expired/invalid token throws AuthenticationException")
|
||||
void testRefreshToken_expired_throws401() {
|
||||
String expiredToken = "expired-refresh-token";
|
||||
when(jwtService.isTokenValid(expiredToken)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> authService.refresh(new RefreshRequest(expiredToken)))
|
||||
.isInstanceOf(AuthService.AuthenticationException.class)
|
||||
.hasMessageContaining("Invalid or expired refresh token");
|
||||
}
|
||||
|
||||
// --- T-20: SHA-256 hashing is deterministic ---
|
||||
|
||||
@Test
|
||||
@DisplayName("SHA-256 hashing is deterministic — same input always produces same hash")
|
||||
void testSha256_deterministic() {
|
||||
String input = "test-refresh-token-abc123";
|
||||
String hash1 = sha256(input);
|
||||
String hash2 = sha256(input);
|
||||
|
||||
assertThat(hash1).isEqualTo(hash2);
|
||||
assertThat(hash1).hasSize(64); // SHA-256 produces 64 hex chars
|
||||
assertThat(hash1).matches("[0-9a-f]{64}");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SHA-256 hashing — different inputs produce different hashes")
|
||||
void testSha256_differentInputs_differentHashes() {
|
||||
String hash1 = sha256("token-one");
|
||||
String hash2 = sha256("token-two");
|
||||
|
||||
assertThat(hash1).isNotEqualTo(hash2);
|
||||
}
|
||||
|
||||
// Helper to replicate AuthService's sha256 logic for test verification
|
||||
private String sha256(String input) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user