test(w7): greenfield consumer integration test
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).
This commit is contained in:
+115
@@ -0,0 +1,115 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>de.platesoft</groupId>
|
||||||
|
<artifactId>plate-auth-parent</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
<relativePath>../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>plate-auth-integration-tests</artifactId>
|
||||||
|
<name>plate-auth-integration-tests</name>
|
||||||
|
<description>Greenfield consumer integration tests for plate-auth-starter</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- The starter under test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>de.platesoft</groupId>
|
||||||
|
<artifactId>plate-auth-starter</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot for running the test app -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Testcontainers Postgres -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<version>${testcontainers.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>${testcontainers.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<!-- Run *IT.java integration tests during verify phase -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-failsafe-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>integration-test</goal>
|
||||||
|
<goal>verify</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<!-- Only run tests, no packaging needed -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>default-jar</id>
|
||||||
|
<phase>none</phase>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-install-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<skip>true</skip>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-deploy-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<skip>true</skip>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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<String> 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<String> first = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
assertEquals(200, first.statusCode());
|
||||||
|
|
||||||
|
// Second call with same nonce fails
|
||||||
|
HttpResponse<String> 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Test consumer application config — minimal for integration tests
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: plate-auth-it
|
||||||
|
jpa:
|
||||||
|
open-in-view: false
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
package de.platesoft.auth;
|
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.*;
|
||||||
import de.platesoft.auth.spi.defaults.*;
|
import de.platesoft.auth.spi.defaults.*;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
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.ConditionalOnMissingBean;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
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.context.annotation.ComponentScan;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
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
|
@AutoConfiguration
|
||||||
@EnableConfigurationProperties(PlateAuthProperties.class)
|
@EnableConfigurationProperties(PlateAuthProperties.class)
|
||||||
@ComponentScan(basePackages = "de.platesoft.auth")
|
@ComponentScan(basePackages = "de.platesoft.auth")
|
||||||
|
@AutoConfigurationPackage(basePackages = "de.platesoft.auth.entity")
|
||||||
@EnableJpaRepositories(basePackages = "de.platesoft.auth.repository")
|
@EnableJpaRepositories(basePackages = "de.platesoft.auth.repository")
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
@ConditionalOnProperty(prefix = "plate.auth", name = "enabled", havingValue = "true", matchIfMissing = true)
|
@ConditionalOnProperty(prefix = "plate.auth", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
@@ -64,54 +52,4 @@ public class PlateAuthAutoConfiguration {
|
|||||||
public OnboardingHook onboardingHook() {
|
public OnboardingHook onboardingHook() {
|
||||||
return new NoOpOnboardingHook();
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -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<String> handleSecurityException(SecurityException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<String> handleIllegalArgument(IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<String> handleGenericException(Exception e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body("Internal error: " + e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package de.platesoft.auth.config;
|
|||||||
|
|
||||||
import org.flywaydb.core.Flyway;
|
import org.flywaydb.core.Flyway;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@@ -11,9 +12,12 @@ import javax.sql.DataSource;
|
|||||||
* Configures a separate Flyway instance for plate-auth migrations.
|
* Configures a separate Flyway instance for plate-auth migrations.
|
||||||
* Uses its own history table (flyway_schema_history_auth) to avoid
|
* Uses its own history table (flyway_schema_history_auth) to avoid
|
||||||
* version collisions with the consumer application's migrations.
|
* version collisions with the consumer application's migrations.
|
||||||
|
*
|
||||||
|
* Disabled when plate.auth.flyway.enabled=false (default: true).
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@ConditionalOnClass(Flyway.class)
|
@ConditionalOnClass(Flyway.class)
|
||||||
|
@ConditionalOnProperty(prefix = "plate.auth.flyway", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
public class PlateAuthFlywayConfig {
|
public class PlateAuthFlywayConfig {
|
||||||
|
|
||||||
@Bean(initMethod = "migrate")
|
@Bean(initMethod = "migrate")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.platesoft.auth.service;
|
package de.platesoft.auth.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import de.platesoft.auth.PlateAuthProperties;
|
import de.platesoft.auth.PlateAuthProperties;
|
||||||
import de.platesoft.auth.dto.ExchangePayload;
|
import de.platesoft.auth.dto.ExchangePayload;
|
||||||
@@ -30,10 +31,12 @@ import java.util.concurrent.ConcurrentMap;
|
|||||||
@Service
|
@Service
|
||||||
public class ExchangeService {
|
public class ExchangeService {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
|
||||||
private final String secret;
|
private final String secret;
|
||||||
private final long maxAgeSeconds;
|
private final long maxAgeSeconds;
|
||||||
private final long nonceTtlSeconds;
|
private final long nonceTtlSeconds;
|
||||||
private final ObjectMapper mapper;
|
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final UserIdentityRepository identityRepository;
|
private final UserIdentityRepository identityRepository;
|
||||||
@@ -44,7 +47,6 @@ public class ExchangeService {
|
|||||||
|
|
||||||
public ExchangeService(
|
public ExchangeService(
|
||||||
PlateAuthProperties props,
|
PlateAuthProperties props,
|
||||||
ObjectMapper mapper,
|
|
||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
UserIdentityRepository identityRepository,
|
UserIdentityRepository identityRepository,
|
||||||
@@ -53,7 +55,6 @@ public class ExchangeService {
|
|||||||
this.secret = props.getExchange().getSecret();
|
this.secret = props.getExchange().getSecret();
|
||||||
this.maxAgeSeconds = props.getExchange().getMaxAge().getSeconds();
|
this.maxAgeSeconds = props.getExchange().getMaxAge().getSeconds();
|
||||||
this.nonceTtlSeconds = props.getExchange().getNonceTtl().getSeconds();
|
this.nonceTtlSeconds = props.getExchange().getNonceTtl().getSeconds();
|
||||||
this.mapper = mapper;
|
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.identityRepository = identityRepository;
|
this.identityRepository = identityRepository;
|
||||||
@@ -78,7 +79,7 @@ public class ExchangeService {
|
|||||||
|
|
||||||
ExchangePayload payload;
|
ExchangePayload payload;
|
||||||
try {
|
try {
|
||||||
payload = mapper.readValue(body, ExchangePayload.class);
|
payload = MAPPER.readValue(body, ExchangePayload.class);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new SecurityException("Malformed exchange payload");
|
throw new SecurityException("Malformed exchange payload");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user