Backend migration

This commit is contained in:
2025-04-27 17:42:02 +00:00
parent e114f9553a
commit 8b1d6eb7cf
52 changed files with 2326 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
package dev.rheinsw.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
* @since 21.04.25
*/
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}

View File

@@ -0,0 +1,85 @@
package dev.rheinsw.gateway.filter;
import dev.rheinsw.gateway.service.ApiKeyValidator;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* @author Thatsaphorn Atchariyaphap
* @since 24.04.25
*/
@Component
@Order(-1)
@RequiredArgsConstructor
public class ApiKeyGatewayFilter implements GlobalFilter {
private static final Logger log = LoggerFactory.getLogger(ApiKeyGatewayFilter.class);
@Value("${gateway.security.frontend-origin}")
private String expectedFrontendOrigin;
private final ApiKeyValidator apiKeyValidator;
@Override
public Mono<Void> filter(ServerWebExchange exchange, org.springframework.cloud.gateway.filter.GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
log.debug("Incoming request for path: {}", path);
if (path.startsWith("/api/public/")) {
log.debug("Public path detected, skipping API key validation");
return chain.filter(exchange);
}
String frontendKey = exchange.getRequest().getHeaders().getFirst("X-Frontend-Key");
String internalKey = exchange.getRequest().getHeaders().getFirst("X-Internal-Auth");
String targetService = resolveTargetServiceFromExchange(exchange);
boolean isFrontend = isFrontendRequest(exchange);
log.debug("Target service resolved: {}, isFrontend: {}", targetService, isFrontend);
if (frontendKey != null && isFrontend) {
log.debug("Validating frontend API key for service: {}", targetService);
if (apiKeyValidator.isAuthorized(frontendKey, targetService, true)) {
log.debug("Frontend API key authorized");
return chain.filter(exchange);
}
log.warn("Shared API key validation failed for service: {}", targetService);
}
if (internalKey != null) {
log.debug("Validating internal API key for service: {}", targetService);
if (apiKeyValidator.isAuthorized(internalKey, targetService, false)) {
log.debug("Internal API key authorized");
return chain.filter(exchange);
}
log.warn("Internal API key validation failed for service: {}", targetService);
}
log.warn("Unauthorized request to {} from origin {}", path, exchange.getRequest().getHeaders().getFirst("Origin"));
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
private String resolveTargetServiceFromExchange(ServerWebExchange exchange) {
Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
return route != null ? route.getId() : "unknown";
}
private boolean isFrontendRequest(ServerWebExchange exchange) {
String origin = exchange.getRequest().getHeaders().getFirst("Origin");
boolean matches = origin != null && origin.trim().equalsIgnoreCase(expectedFrontendOrigin.trim());
log.debug("Origin header: {}, expected: {}, matches: {}", origin, expectedFrontendOrigin, matches);
return matches;
}
}

View File

@@ -0,0 +1,61 @@
package dev.rheinsw.gateway.model;
import dev.rheinsw.shared.entity.BaseEntity;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.Set;
/**
* @author Thatsaphorn Atchariyaphap
* @since 25.04.25
*/
@Entity
@Table(name = "api_key")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ApiKey extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "api_key", unique = true, nullable = false)
private String apiKey;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ApiKeyType type;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "api_key_services", joinColumns = @JoinColumn(name = "api_key_id"))
@Column(name = "allowed_service")
private Set<String> allowedServices;
@Column(nullable = false)
private boolean enabled;
@Column(name = "frontend_only", nullable = false)
private boolean frontendOnly;
@Column
private String description;
}

View File

@@ -0,0 +1,10 @@
package dev.rheinsw.gateway.model;
/**
* @author Thatsaphorn Atchariyaphap
* @since 25.04.25
*/
public enum ApiKeyType {
FRONTEND,
INTERNAL;
}

View File

@@ -0,0 +1,14 @@
package dev.rheinsw.gateway.repository;
import dev.rheinsw.gateway.model.ApiKey;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* @author Thatsaphorn Atchariyaphap
* @since 25.04.25
*/
public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
Optional<ApiKey> findByApiKey(String apiKey);
}

View File

@@ -0,0 +1,27 @@
package dev.rheinsw.gateway.service;
import dev.rheinsw.gateway.model.ApiKey;
import dev.rheinsw.gateway.repository.ApiKeyRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* @author Thatsaphorn Atchariyaphap
* @since 25.04.25
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ApiKeyValidator {
private final ApiKeyRepository apiKeyRepository;
public boolean isAuthorized(String key, String targetService, boolean isFrontendRequest) {
return apiKeyRepository.findByApiKey(key)
.filter(ApiKey::isEnabled)
.filter(apiKey -> !apiKey.isFrontendOnly() || isFrontendRequest)
.filter(apiKey -> apiKey.getAllowedServices().contains(targetService))
.isPresent();
}
}

View File

@@ -0,0 +1,41 @@
server:
port: 8080
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
gateway:
security:
frontend-origin: ${FRONTEND_ORIGIN}
spring:
application:
name: gateway
datasource:
url: jdbc:postgresql://localhost:5432/rheinsw_dev
username: rheinsw
password: rheinsw
jpa:
hibernate:
ddl-auto: none
show-sql: true
properties:
hibernate:
format_sql: true
cloud:
gateway:
routes:
- id: contactService
uri: lb://contactService
predicates:
- Path=/api/contact/**
filters:
- StripPrefix=1
- id: mailService
uri: lb://mailService
predicates:
- Path=/api/mail/**
filters:
- StripPrefix=1

View File

@@ -0,0 +1,58 @@
-- 1. Create tables with timestamps correctly
CREATE TABLE api_key
(
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
api_key VARCHAR(255) NOT NULL,
type VARCHAR(255) NOT NULL,
enabled BOOLEAN NOT NULL,
frontend_only BOOLEAN NOT NULL,
createdDateTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modifiedDateTime TIMESTAMP NULL,
CONSTRAINT pk_api_key PRIMARY KEY (id)
);
CREATE TABLE api_key_services
(
api_key_id BIGINT NOT NULL,
allowed_service VARCHAR(255),
createdDateTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modifiedDateTime TIMESTAMP NULL
);
-- 2. Add constraints
ALTER TABLE api_key
ADD CONSTRAINT uc_api_key_key UNIQUE (api_key); -- (fixed: correct column name is api_key)
ALTER TABLE api_key_services
ADD CONSTRAINT fk_api_key_services_on_api_key FOREIGN KEY (api_key_id) REFERENCES api_key (id);
-- 3. Function to update modifiedDateTime if any real change occurs
CREATE
OR REPLACE FUNCTION set_modified_datetime()
RETURNS TRIGGER AS $$
BEGIN
IF
(OLD IS DISTINCT FROM NEW) THEN
NEW.modifiedDateTime = CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END;
$$
LANGUAGE plpgsql;
-- 4. Triggers to update modifiedDateTime
CREATE TRIGGER trg_set_modified_datetime_api_key
BEFORE UPDATE
ON api_key
FOR EACH ROW
EXECUTE FUNCTION set_modified_datetime();
CREATE TRIGGER trg_set_modified_datetime_api_key_services
BEFORE UPDATE
ON api_key_services
FOR EACH ROW
EXECUTE FUNCTION set_modified_datetime();

View File

@@ -0,0 +1,121 @@
package dev.rheinsw.gateway.filter;
import dev.rheinsw.gateway.service.ApiKeyValidator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.test.util.ReflectionTestUtils;
import reactor.core.publisher.Mono;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class ApiKeyGatewayFilterTest {
@Mock
private ApiKeyValidator apiKeyValidator;
@Mock
private GatewayFilterChain chain;
private ApiKeyGatewayFilter filter;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
filter = new ApiKeyGatewayFilter(apiKeyValidator);
// Inject expectedFrontendOrigin manually
ReflectionTestUtils.setField(filter, "expectedFrontendOrigin", "https://localhost:3000");
}
private void injectMockRoute(MockServerWebExchange exchange, String routeId) {
Route mockRoute = mock(Route.class);
when(mockRoute.getId()).thenReturn(routeId);
exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR, mockRoute);
}
@Test
void shouldAllowPublicRequestWithoutValidation() {
MockServerHttpRequest request = MockServerHttpRequest.get("/api/public/test").build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
when(chain.filter(exchange)).thenReturn(Mono.empty());
filter.filter(exchange, chain).block();
verify(chain, times(1)).filter(exchange);
}
@Test
void shouldAllowValidFrontendApiKey() {
MockServerHttpRequest request = MockServerHttpRequest.get("/api/contact/test")
.header("Origin", "https://localhost:3000")
.header("X-Frontend-Key", "valid-frontend-key")
.build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
injectMockRoute(exchange, "contactService");
when(apiKeyValidator.isAuthorized(eq("valid-frontend-key"), eq("contactService"), eq(true))).thenReturn(true);
when(chain.filter(exchange)).thenReturn(Mono.empty());
filter.filter(exchange, chain).block();
verify(apiKeyValidator).isAuthorized("valid-frontend-key", "contactService", true);
verify(chain, times(1)).filter(exchange);
}
@Test
void shouldRejectInvalidFrontendApiKey() {
MockServerHttpRequest request = MockServerHttpRequest.get("/api/contact/test")
.header("Origin", "https://localhost:3000")
.header("X-Frontend-Key", "invalid-frontend-key")
.build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
injectMockRoute(exchange, "contactService");
when(apiKeyValidator.isAuthorized(eq("invalid-frontend-key"), eq("contactService"), eq(true))).thenReturn(false);
filter.filter(exchange, chain).block();
assertEquals(exchange.getResponse().getStatusCode(), org.springframework.http.HttpStatus.UNAUTHORIZED);
}
@Test
void shouldAllowValidInternalApiKey() {
MockServerHttpRequest request = MockServerHttpRequest.get("/api/contact/test")
.header("X-Internal-Auth", "valid-internal-key")
.build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
injectMockRoute(exchange, "contactService");
when(apiKeyValidator.isAuthorized(eq("valid-internal-key"), eq("contactService"), eq(false))).thenReturn(true);
when(chain.filter(exchange)).thenReturn(Mono.empty());
filter.filter(exchange, chain).block();
verify(apiKeyValidator).isAuthorized("valid-internal-key", "contactService", false);
verify(chain, times(1)).filter(exchange);
}
@Test
void shouldRejectInvalidInternalApiKey() {
MockServerHttpRequest request = MockServerHttpRequest.get("/api/contact/test")
.header("X-Internal-Auth", "invalid-internal-key")
.build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
injectMockRoute(exchange, "contactService");
when(apiKeyValidator.isAuthorized(eq("invalid-internal-key"), eq("contactService"), eq(false))).thenReturn(false);
filter.filter(exchange, chain).block();
assertEquals(exchange.getResponse().getStatusCode(), org.springframework.http.HttpStatus.UNAUTHORIZED);
}
}

View File

@@ -0,0 +1,51 @@
package dev.rheinsw.gateway.security;
import dev.rheinsw.gateway.model.ApiKey;
import dev.rheinsw.gateway.model.ApiKeyType;
import dev.rheinsw.gateway.repository.ApiKeyRepository;
import dev.rheinsw.gateway.service.ApiKeyValidator;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class ApiKeyValidatorTest {
private final ApiKeyRepository repository = mock(ApiKeyRepository.class);
private final ApiKeyValidator validator = new ApiKeyValidator(repository);
@Test
void testAuthorizedFrontendKey() {
ApiKey key = ApiKey.builder()
.apiKey("frontend-key")
.type(ApiKeyType.FRONTEND)
.enabled(true)
.frontendOnly(true)
.allowedServices(Set.of("contactService"))
.build();
when(repository.findByApiKey("frontend-key")).thenReturn(Optional.of(key));
boolean result = validator.isAuthorized("frontend-key", "contactService", true);
assertTrue(result);
}
@Test
void testUnauthorizedDueToWrongService() {
ApiKey key = ApiKey.builder()
.apiKey("internal-key")
.type(ApiKeyType.INTERNAL)
.enabled(true)
.frontendOnly(false)
.allowedServices(Set.of("mailService"))
.build();
when(repository.findByApiKey("internal-key")).thenReturn(Optional.of(key));
boolean result = validator.isAuthorized("internal-key", "contactService", false);
assertFalse(result);
}
}

View File

@@ -0,0 +1 @@
spring.cloud.gateway.mvc-discovery.enabled=false