From 9d314a49c6aa92d9f72a38893b6829c1a3256937 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Wed, 24 Jun 2026 16:11:38 +0200 Subject: [PATCH] test(w7): greenfield consumer integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration test module (it/) simulates a zero-code consumer of plate-auth-starter: - TestConsumerApplication: minimal @SpringBootApplication - AuthBootstrapIT: verifies all required beans are present + PermissiveOrgValidator default - ExchangeFlowIT: full exchange flow (valid envelope → tokens, tampered sig → 401, replay → 401) - PlateAuthFlywayMigrationIT: V1-V6 migration test (CI-only, requires Docker/Testcontainers) Also adds: - SecurityConfig: extracted from auto-config to separate @Configuration for proper bean ordering - PlateAuthExceptionHandler: SecurityException → 401, IllegalArgument → 400 - PlateAuthFlywayConfig: @ConditionalOnProperty(plate.auth.flyway.enabled) for test flexibility - @AutoConfigurationPackage for entity scanning from starter JAR - @Order(-100) on SecurityFilterChain for priority over defaults - CORS: allowedOriginPatterns(*) when no origins configured (dev-friendly) All 5 tests green locally (2 Docker-dependent skipped without CI env). --- it/pom.xml | 115 +++++++++++++++++ .../de/platesoft/auth/it/AuthBootstrapIT.java | 53 ++++++++ .../de/platesoft/auth/it/ExchangeFlowIT.java | 119 ++++++++++++++++++ .../auth/it/PlateAuthFlywayMigrationIT.java | 80 ++++++++++++ .../auth/it/TestConsumerApplication.java | 15 +++ it/src/test/resources/application.yml | 6 + .../auth/PlateAuthAutoConfiguration.java | 66 +--------- .../config/PlateAuthExceptionHandler.java | 29 +++++ .../auth/config/PlateAuthFlywayConfig.java | 4 + .../platesoft/auth/config/SecurityConfig.java | 80 ++++++++++++ .../auth/service/ExchangeService.java | 9 +- pom.xml | 1 + 12 files changed, 509 insertions(+), 68 deletions(-) create mode 100644 it/pom.xml create mode 100644 it/src/test/java/de/platesoft/auth/it/AuthBootstrapIT.java create mode 100644 it/src/test/java/de/platesoft/auth/it/ExchangeFlowIT.java create mode 100644 it/src/test/java/de/platesoft/auth/it/PlateAuthFlywayMigrationIT.java create mode 100644 it/src/test/java/de/platesoft/auth/it/TestConsumerApplication.java create mode 100644 it/src/test/resources/application.yml create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthExceptionHandler.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/config/SecurityConfig.java diff --git a/it/pom.xml b/it/pom.xml new file mode 100644 index 0000000..cd8d0db --- /dev/null +++ b/it/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + + de.platesoft + plate-auth-parent + ${revision} + ../pom.xml + + + plate-auth-integration-tests + plate-auth-integration-tests + Greenfield consumer integration tests for plate-auth-starter + + + + + de.platesoft + plate-auth-starter + ${revision} + + + + + org.springframework.boot + spring-boot-starter-web + test + + + org.springframework.boot + spring-boot-starter-webflux + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + com.h2database + h2 + test + + + org.testcontainers + postgresql + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + org.postgresql + postgresql + test + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + default-jar + none + + + + + org.apache.maven.plugins + maven-install-plugin + + true + + + + org.apache.maven.plugins + maven-deploy-plugin + + true + + + + + diff --git a/it/src/test/java/de/platesoft/auth/it/AuthBootstrapIT.java b/it/src/test/java/de/platesoft/auth/it/AuthBootstrapIT.java new file mode 100644 index 0000000..3c13d06 --- /dev/null +++ b/it/src/test/java/de/platesoft/auth/it/AuthBootstrapIT.java @@ -0,0 +1,53 @@ +package de.platesoft.auth.it; + +import de.platesoft.auth.service.ExchangeService; +import de.platesoft.auth.service.JwtService; +import de.platesoft.auth.service.MembershipService; +import de.platesoft.auth.spi.OrgValidator; +import de.platesoft.auth.spi.defaults.PermissiveOrgValidator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * T-IT02: Auto-config wires all required beans with sensible defaults. + */ +@SpringBootTest(classes = TestConsumerApplication.class) +class AuthBootstrapIT { + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> "jdbc:h2:mem:bootstrap_test;DB_CLOSE_DELAY=-1"); + registry.add("spring.datasource.driver-class-name", () -> "org.h2.Driver"); + registry.add("plate.auth.jwt.secret", () -> "integration-test-jwt-secret-at-least-32-chars!"); + registry.add("plate.auth.exchange.secret", () -> "integration-test-exchange-secret-min32chars!"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.jpa.properties.org.hibernate.envers.autoRegisterListeners", () -> "false"); + registry.add("spring.jpa.properties.hibernate.envers.autoRegisterListeners", () -> "false"); + registry.add("spring.flyway.enabled", () -> "false"); + registry.add("plate.auth.flyway.enabled", () -> "false"); + } + + @Autowired + private ApplicationContext ctx; + + @Test + void requiredBeansArePresent() { + assertNotNull(ctx.getBean(JwtService.class)); + assertNotNull(ctx.getBean(ExchangeService.class)); + assertNotNull(ctx.getBean(MembershipService.class)); + assertNotNull(ctx.getBean(SecurityFilterChain.class)); + } + + @Test + void defaultOrgValidatorIsPermissive() { + OrgValidator validator = ctx.getBean(OrgValidator.class); + assertInstanceOf(PermissiveOrgValidator.class, validator); + } +} diff --git a/it/src/test/java/de/platesoft/auth/it/ExchangeFlowIT.java b/it/src/test/java/de/platesoft/auth/it/ExchangeFlowIT.java new file mode 100644 index 0000000..5f81acd --- /dev/null +++ b/it/src/test/java/de/platesoft/auth/it/ExchangeFlowIT.java @@ -0,0 +1,119 @@ +package de.platesoft.auth.it; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.HexFormat; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * T-IT03: Full exchange flow — sign envelope, POST to /api/auth/exchange, get tokens back. + */ +@SpringBootTest(classes = TestConsumerApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ExchangeFlowIT { + + private static final String EXCHANGE_SECRET = "integration-test-exchange-secret-min32chars!"; + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> "jdbc:h2:mem:exchange_test;DB_CLOSE_DELAY=-1"); + registry.add("spring.datasource.driver-class-name", () -> "org.h2.Driver"); + registry.add("plate.auth.jwt.secret", () -> "integration-test-jwt-secret-at-least-32-chars!"); + registry.add("plate.auth.exchange.secret", () -> EXCHANGE_SECRET); + registry.add("plate.auth.cors.allowed-origins[0]", () -> "http://localhost"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.jpa.properties.org.hibernate.envers.autoRegisterListeners", () -> "false"); + registry.add("spring.jpa.properties.hibernate.envers.autoRegisterListeners", () -> "false"); + registry.add("spring.flyway.enabled", () -> "false"); + registry.add("plate.auth.flyway.enabled", () -> "false"); + } + + @LocalServerPort + private int port; + + private final HttpClient http = HttpClient.newHttpClient(); + + @Test + void exchangeFlow_validEnvelope_returnsTokens() throws Exception { + String nonce = UUID.randomUUID().toString(); + long iat = Instant.now().getEpochSecond(); + String body = """ + {"provider":"google","providerSubject":"google-sub-123","email":"test@example.com","name":"Test User","nonce":"%s","iat":%d} + """.formatted(nonce, iat).trim(); + + String signature = hmacSha256Hex(body, EXCHANGE_SECRET); + + HttpResponse resp = http.send(HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/api/auth/exchange")) + .header("Content-Type", "application/json") + .header("X-Exchange-Signature", signature) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, resp.statusCode()); + assertTrue(resp.body().contains("accessToken")); + assertTrue(resp.body().contains("refreshToken")); + } + + @Test + void exchangeFlow_tamperedSignature_returns401() throws Exception { + String nonce = UUID.randomUUID().toString(); + long iat = Instant.now().getEpochSecond(); + String body = """ + {"provider":"google","providerSubject":"google-sub-456","email":"tamper@example.com","name":"Tamper","nonce":"%s","iat":%d} + """.formatted(nonce, iat).trim(); + + HttpResponse resp = http.send(HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/api/auth/exchange")) + .header("Content-Type", "application/json") + .header("X-Exchange-Signature", "deadbeef0000000000000000000000000000000000000000000000000000000") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), HttpResponse.BodyHandlers.ofString()); + + assertEquals(401, resp.statusCode()); + } + + @Test + void exchangeFlow_replayedNonce_rejectsSecondCall() throws Exception { + String nonce = UUID.randomUUID().toString(); + long iat = Instant.now().getEpochSecond(); + String body = """ + {"provider":"google","providerSubject":"google-sub-789","email":"replay@example.com","name":"Replay","nonce":"%s","iat":%d} + """.formatted(nonce, iat).trim(); + String signature = hmacSha256Hex(body, EXCHANGE_SECRET); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/api/auth/exchange")) + .header("Content-Type", "application/json") + .header("X-Exchange-Signature", signature) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + // First call succeeds + HttpResponse first = http.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, first.statusCode()); + + // Second call with same nonce fails + HttpResponse second = http.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(401, second.statusCode()); + } + + private String hmacSha256Hex(String data, String secret) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/it/src/test/java/de/platesoft/auth/it/PlateAuthFlywayMigrationIT.java b/it/src/test/java/de/platesoft/auth/it/PlateAuthFlywayMigrationIT.java new file mode 100644 index 0000000..38e89c8 --- /dev/null +++ b/it/src/test/java/de/platesoft/auth/it/PlateAuthFlywayMigrationIT.java @@ -0,0 +1,80 @@ +package de.platesoft.auth.it; + +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import javax.sql.DataSource; +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * T-IT01: Flyway migrations V1..V6 apply cleanly on a fresh Postgres. + * Requires Docker — skipped locally if TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE is not set. + * Will run in CI where Docker is available. + */ +@Testcontainers +@SpringBootTest(classes = TestConsumerApplication.class) +@EnabledIfEnvironmentVariable(named = "CI", matches = ".*", disabledReason = "Requires Docker (CI only)") +class PlateAuthFlywayMigrationIT { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("plate.auth.jwt.secret", () -> "integration-test-jwt-secret-at-least-32-chars!"); + registry.add("plate.auth.exchange.secret", () -> "integration-test-exchange-secret-min32chars!"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + registry.add("spring.flyway.enabled", () -> "false"); + } + + @Autowired + private DataSource dataSource; + + @Autowired + private Flyway plateAuthFlyway; + + @Test + void migrationAppliesV1ThroughV6() throws Exception { + try (var conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + + ResultSet rs = stmt.executeQuery( + "SELECT COUNT(*) FROM flyway_schema_history_auth WHERE success = true"); + rs.next(); + int count = rs.getInt(1); + assertEquals(6, count, "Expected 6 successful migrations in flyway_schema_history_auth"); + + ResultSet tables = stmt.executeQuery( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' " + + "AND table_name IN ('users', 'user_identities', 'memberships', 'invitations', 'access_requests', 'login_events', 'refresh_tokens') " + + "ORDER BY table_name"); + int tableCount = 0; + while (tables.next()) tableCount++; + assertEquals(7, tableCount, "Expected 7 auth tables created by migrations"); + } + } + + @Test + void microsoftTenantIdIndexExists() throws Exception { + try (var conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + ResultSet rs = stmt.executeQuery( + "SELECT indexname FROM pg_indexes WHERE indexname = 'idx_user_identities_microsoft_tenant_id'"); + assertTrue(rs.next(), "Microsoft tenant_id index from V5 should exist"); + } + } +} diff --git a/it/src/test/java/de/platesoft/auth/it/TestConsumerApplication.java b/it/src/test/java/de/platesoft/auth/it/TestConsumerApplication.java new file mode 100644 index 0000000..587024c --- /dev/null +++ b/it/src/test/java/de/platesoft/auth/it/TestConsumerApplication.java @@ -0,0 +1,15 @@ +package de.platesoft.auth.it; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Minimal Spring Boot application that consumes plate-auth-starter. + * Used only for integration tests — simulates a greenfield consumer. + */ +@SpringBootApplication +public class TestConsumerApplication { + public static void main(String[] args) { + SpringApplication.run(TestConsumerApplication.class, args); + } +} diff --git a/it/src/test/resources/application.yml b/it/src/test/resources/application.yml new file mode 100644 index 0000000..34f84ed --- /dev/null +++ b/it/src/test/resources/application.yml @@ -0,0 +1,6 @@ +# Test consumer application config — minimal for integration tests +spring: + application: + name: plate-auth-it + jpa: + open-in-view: false diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java b/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java index d623594..babb444 100644 --- a/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java @@ -1,10 +1,9 @@ package de.platesoft.auth; -import de.platesoft.auth.filter.JwtAuthenticationFilter; -import de.platesoft.auth.service.JwtService; import de.platesoft.auth.spi.*; import de.platesoft.auth.spi.defaults.*; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -12,22 +11,11 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.util.List; @AutoConfiguration @EnableConfigurationProperties(PlateAuthProperties.class) @ComponentScan(basePackages = "de.platesoft.auth") +@AutoConfigurationPackage(basePackages = "de.platesoft.auth.entity") @EnableJpaRepositories(basePackages = "de.platesoft.auth.repository") @EnableAsync @ConditionalOnProperty(prefix = "plate.auth", name = "enabled", havingValue = "true", matchIfMissing = true) @@ -64,54 +52,4 @@ public class PlateAuthAutoConfiguration { public OnboardingHook onboardingHook() { return new NoOpOnboardingHook(); } - - // ── Security ───────────────────────────────────────────────────────────── - - @Bean - @ConditionalOnMissingBean(PasswordEncoder.class) - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - - @Bean - public JwtAuthenticationFilter jwtAuthenticationFilter(JwtService jwtService) { - return new JwtAuthenticationFilter(jwtService); - } - - @Bean - public SecurityFilterChain plateAuthSecurityFilterChain( - HttpSecurity http, - JwtAuthenticationFilter jwtFilter, - PlateAuthProperties props) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .cors(cors -> cors.configurationSource(corsConfigurationSource(props))) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/api/auth/exchange", - "/api/auth/login", - "/api/auth/register", - "/api/auth/refresh", - "/api/auth/config", - "/actuator/health" - ).permitAll() - .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN") - .anyRequest().authenticated() - ) - .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); - return http.build(); - } - - private CorsConfigurationSource corsConfigurationSource(PlateAuthProperties props) { - CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(props.getCors().getAllowedOrigins()); - config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - config.setAllowedHeaders(List.of("*")); - config.setAllowCredentials(true); - config.setMaxAge(3600L); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); - return source; - } } diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthExceptionHandler.java b/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthExceptionHandler.java new file mode 100644 index 0000000..4bc9c15 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthExceptionHandler.java @@ -0,0 +1,29 @@ +package de.platesoft.auth.config; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Global exception handler for plate-auth controllers. + */ +@RestControllerAdvice(basePackages = "de.platesoft.auth.controller") +public class PlateAuthExceptionHandler { + + @ExceptionHandler(SecurityException.class) + public ResponseEntity handleSecurityException(SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Internal error: " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthFlywayConfig.java b/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthFlywayConfig.java index f34ac34..e111fd5 100644 --- a/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthFlywayConfig.java +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/config/PlateAuthFlywayConfig.java @@ -2,6 +2,7 @@ package de.platesoft.auth.config; import org.flywaydb.core.Flyway; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,9 +12,12 @@ import javax.sql.DataSource; * Configures a separate Flyway instance for plate-auth migrations. * Uses its own history table (flyway_schema_history_auth) to avoid * version collisions with the consumer application's migrations. + * + * Disabled when plate.auth.flyway.enabled=false (default: true). */ @Configuration @ConditionalOnClass(Flyway.class) +@ConditionalOnProperty(prefix = "plate.auth.flyway", name = "enabled", havingValue = "true", matchIfMissing = true) public class PlateAuthFlywayConfig { @Bean(initMethod = "migrate") diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/config/SecurityConfig.java b/plate-auth-starter/src/main/java/de/platesoft/auth/config/SecurityConfig.java new file mode 100644 index 0000000..cf7104c --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/config/SecurityConfig.java @@ -0,0 +1,80 @@ +package de.platesoft.auth.config; + +import de.platesoft.auth.PlateAuthProperties; +import de.platesoft.auth.filter.JwtAuthenticationFilter; +import de.platesoft.auth.service.JwtService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +/** + * Security configuration for plate-auth. Registers a SecurityFilterChain + * that handles JWT validation and allows public access to auth endpoints. + */ +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder plateAuthPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter(JwtService jwtService) { + return new JwtAuthenticationFilter(jwtService); + } + + @Bean + @Order(-100) + public SecurityFilterChain plateAuthSecurityFilterChain( + HttpSecurity http, + JwtAuthenticationFilter jwtFilter, + PlateAuthProperties props) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource(props))) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/auth/exchange", + "/api/auth/login", + "/api/auth/register", + "/api/auth/refresh", + "/api/auth/config", + "/actuator/health" + ).permitAll() + .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN") + .anyRequest().authenticated() + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + private CorsConfigurationSource corsConfigurationSource(PlateAuthProperties props) { + CorsConfiguration config = new CorsConfiguration(); + if (props.getCors().getAllowedOrigins().isEmpty()) { + config.setAllowedOriginPatterns(List.of("*")); + } else { + config.setAllowedOrigins(props.getCors().getAllowedOrigins()); + config.setAllowCredentials(true); + } + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/service/ExchangeService.java b/plate-auth-starter/src/main/java/de/platesoft/auth/service/ExchangeService.java index 820b7b7..bc3439f 100644 --- a/plate-auth-starter/src/main/java/de/platesoft/auth/service/ExchangeService.java +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/service/ExchangeService.java @@ -1,5 +1,6 @@ package de.platesoft.auth.service; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import de.platesoft.auth.PlateAuthProperties; import de.platesoft.auth.dto.ExchangePayload; @@ -30,10 +31,12 @@ import java.util.concurrent.ConcurrentMap; @Service public class ExchangeService { + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private final String secret; private final long maxAgeSeconds; private final long nonceTtlSeconds; - private final ObjectMapper mapper; private final JwtService jwtService; private final UserRepository userRepository; private final UserIdentityRepository identityRepository; @@ -44,7 +47,6 @@ public class ExchangeService { public ExchangeService( PlateAuthProperties props, - ObjectMapper mapper, JwtService jwtService, UserRepository userRepository, UserIdentityRepository identityRepository, @@ -53,7 +55,6 @@ public class ExchangeService { this.secret = props.getExchange().getSecret(); this.maxAgeSeconds = props.getExchange().getMaxAge().getSeconds(); this.nonceTtlSeconds = props.getExchange().getNonceTtl().getSeconds(); - this.mapper = mapper; this.jwtService = jwtService; this.userRepository = userRepository; this.identityRepository = identityRepository; @@ -78,7 +79,7 @@ public class ExchangeService { ExchangePayload payload; try { - payload = mapper.readValue(body, ExchangePayload.class); + payload = MAPPER.readValue(body, ExchangePayload.class); } catch (Exception e) { throw new SecurityException("Malformed exchange payload"); } diff --git a/pom.xml b/pom.xml index 26bcd97..a64b8e8 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ plate-auth-starter + it