Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/shared-libs-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jobs:
ftgo-common-lib: ${{ steps.filter.outputs.ftgo-common-lib }}
ftgo-common-jpa: ${{ steps.filter.outputs.ftgo-common-jpa }}
ftgo-domain: ${{ steps.filter.outputs.ftgo-domain }}
ftgo-security-lib: ${{ steps.filter.outputs.ftgo-security-lib }}
platform: ${{ steps.filter.outputs.platform }}
steps:
- uses: actions/checkout@v4
Expand All @@ -40,6 +41,8 @@ jobs:
ftgo-domain:
- 'libs/ftgo-domain/**'
- 'libs/ftgo-domain-lib/**'
ftgo-security-lib:
- 'libs/ftgo-security-lib/**'
platform:
- 'buildSrc-platform/**'
- 'gradle/libs.versions.toml'
Expand Down Expand Up @@ -77,6 +80,38 @@ jobs:
path: 'libs/ftgo-common-lib/build/reports/tests/'
retention-days: 14

build-ftgo-security-lib:
name: Build ftgo-security-lib
needs: detect-changes
if: needs.detect-changes.outputs.ftgo-security-lib == 'true' || needs.detect-changes.outputs.platform == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-security-lib-${{ hashFiles('libs/ftgo-security-lib/**/*.gradle', 'gradle/platform/libs.versions.toml') }}
restore-keys: ${{ runner.os }}-gradle-security-lib-
- name: Build and test
run: |
chmod +x gradlew
cd libs/ftgo-security-lib && ../../gradlew compileJava test
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: ftgo-security-lib-test-reports
path: 'libs/ftgo-security-lib/build/reports/tests/'
retention-days: 14

build-monolith-regression:
name: Monolith Regression Check
needs: detect-changes
Expand Down
11 changes: 11 additions & 0 deletions gradle/platform/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ rest-assured = "5.4.0"
mockito = "5.8.0"
testcontainers = "1.19.3"

# Security
jjwt = "0.12.3"
spring-security = "6.2.1"

# Observability
micrometer = "1.12.2"
micrometer-tracing = "1.2.2"
Expand All @@ -44,6 +48,12 @@ spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-
spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" }

# Security
spring-security-test = { module = "org.springframework.security:spring-security-test", version.ref = "spring-security" }
jjwt-api = { module = "io.jsonwebtoken:jjwt-api", version.ref = "jjwt" }
jjwt-impl = { module = "io.jsonwebtoken:jjwt-impl", version.ref = "jjwt" }
jjwt-jackson = { module = "io.jsonwebtoken:jjwt-jackson", version.ref = "jjwt" }

# Database
flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
flyway-mysql = { module = "org.flywaydb:flyway-mysql", version.ref = "flyway" }
Expand Down Expand Up @@ -80,6 +90,7 @@ mapstruct-processor = { module = "org.mapstruct:mapstruct-processor", version.re
spring-web = ["spring-boot-starter-web", "spring-boot-starter-actuator", "spring-boot-starter-validation"]
spring-data = ["spring-boot-starter-data-jpa", "flyway-core", "flyway-mysql"]
spring-test = ["spring-boot-starter-test", "junit-jupiter", "mockito-core", "mockito-junit"]
spring-security = ["spring-boot-starter-security", "jjwt-api"]
observability = ["micrometer-registry-prometheus", "micrometer-tracing-bridge-brave"]

[plugins]
Expand Down
7 changes: 4 additions & 3 deletions libs/ftgo-security-lib/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@ dependencies {
compileOnly 'org.springframework.boot:spring-boot-autoconfigure:3.2.0'

// Testing
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1'
testImplementation 'org.springframework.boot:spring-boot-starter-web:3.2.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test:3.2.0'
testImplementation 'org.springframework.security:spring-security-test:6.2.0'
testImplementation 'org.springframework.security:spring-security-test:6.2.1'
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1'
}

tasks.named('test') {
test {
useJUnitPlatform()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
@EnableWebSecurity
public class FtgoSecurityConfiguration {

private final SecurityExceptionHandler securityExceptionHandler = new SecurityExceptionHandler();

@Bean
public SecurityFilterChain ftgoSecurityFilterChain(HttpSecurity http) throws Exception {
http
Expand All @@ -44,6 +46,11 @@ public SecurityFilterChain ftgoSecurityFilterChain(HttpSecurity http) throws Exc
// All other endpoints require authentication
.anyRequest().authenticated())

// Exception handling with JSON responses
.exceptionHandling(ex -> ex
.authenticationEntryPoint(securityExceptionHandler)
.accessDeniedHandler(securityExceptionHandler))

// CORS configuration
.cors(cors -> cors.configurationSource(new FtgoCorsConfigurationSource()));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
Expand Down Expand Up @@ -49,25 +48,25 @@ private String generateToken(String subject, List<String> roles, long expiration
Date expiry = new Date(now.getTime() + expirationMs);

var builder = Jwts.builder()
.setSubject(subject)
.setIssuer(issuer)
.setIssuedAt(now)
.setExpiration(expiry)
.subject(subject)
.issuer(issuer)
.issuedAt(now)
.expiration(expiry)
.claim("type", tokenType);

if (roles != null) {
builder.claim("roles", roles);
}

return builder.signWith(signingKey, SignatureAlgorithm.HS256).compact();
return builder.signWith(signingKey).compact();
}

public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(signingKey)
return Jwts.parser()
.verifyWith(signingKey)
.build()
.parseClaimsJws(token)
.getBody();
.parseSignedClaims(token)
.getPayload();
}

public boolean validateToken(String token) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package net.chrisrichardson.ftgo.security;

import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.cors.CorsConfiguration;

import static org.junit.jupiter.api.Assertions.*;

class FtgoCorsConfigurationSourceTest {

private final FtgoCorsConfigurationSource source = new FtgoCorsConfigurationSource();

@Test
void shouldAllowAllOriginPatterns() {
MockHttpServletRequest request = new MockHttpServletRequest();
CorsConfiguration config = source.getCorsConfiguration(request);

assertNotNull(config);
assertNotNull(config.getAllowedOriginPatterns());
assertTrue(config.getAllowedOriginPatterns().contains("*"));
}

@Test
void shouldAllowStandardHttpMethods() {
MockHttpServletRequest request = new MockHttpServletRequest();
CorsConfiguration config = source.getCorsConfiguration(request);

assertNotNull(config.getAllowedMethods());
assertTrue(config.getAllowedMethods().contains("GET"));
assertTrue(config.getAllowedMethods().contains("POST"));
assertTrue(config.getAllowedMethods().contains("PUT"));
assertTrue(config.getAllowedMethods().contains("PATCH"));
assertTrue(config.getAllowedMethods().contains("DELETE"));
assertTrue(config.getAllowedMethods().contains("OPTIONS"));
}

@Test
void shouldAllowRequiredHeaders() {
MockHttpServletRequest request = new MockHttpServletRequest();
CorsConfiguration config = source.getCorsConfiguration(request);

assertNotNull(config.getAllowedHeaders());
assertTrue(config.getAllowedHeaders().contains("Authorization"));
assertTrue(config.getAllowedHeaders().contains("Content-Type"));
}

@Test
void shouldExposeAuthorizationHeader() {
MockHttpServletRequest request = new MockHttpServletRequest();
CorsConfiguration config = source.getCorsConfiguration(request);

assertNotNull(config.getExposedHeaders());
assertTrue(config.getExposedHeaders().contains("Authorization"));
assertTrue(config.getExposedHeaders().contains("X-Request-Id"));
}

@Test
void shouldAllowCredentials() {
MockHttpServletRequest request = new MockHttpServletRequest();
CorsConfiguration config = source.getCorsConfiguration(request);

assertTrue(config.getAllowCredentials());
}

@Test
void shouldSetMaxAge() {
MockHttpServletRequest request = new MockHttpServletRequest();
CorsConfiguration config = source.getCorsConfiguration(request);

assertEquals(3600L, config.getMaxAge());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package net.chrisrichardson.ftgo.security;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
class FtgoSecurityConfigurationIntegrationTest {

@SpringBootApplication
@RestController
static class TestApp {
@GetMapping("/api/test")
public String securedEndpoint() {
return "secured";
}
}

@Autowired
private MockMvc mockMvc;

@Test
void actuatorHealthEndpointShouldBePublic() throws Exception {
mockMvc.perform(get("/actuator/health"))
.andExpect(status().isOk());
}

@Test
void actuatorInfoEndpointShouldBePublic() throws Exception {
mockMvc.perform(get("/actuator/info"))
.andExpect(status().isOk());
}

@Test
void securedEndpointShouldReturn401WithoutAuth() throws Exception {
mockMvc.perform(get("/api/test"))
.andExpect(status().isUnauthorized())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.status").value(401))
.andExpect(jsonPath("$.error").value("UNAUTHORIZED"))
.andExpect(jsonPath("$.message").exists())
.andExpect(jsonPath("$.path").value("/api/test"))
.andExpect(jsonPath("$.timestamp").exists());
}

@Test
void swaggerUiEndpointShouldNotRequireAuth() throws Exception {
mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isNotFound());
}

@Test
void csrfShouldBeDisabledForStatelessApis() throws Exception {
mockMvc.perform(get("/actuator/health"))
.andExpect(status().isOk())
.andExpect(cookie().doesNotExist("XSRF-TOKEN"));
}

@Test
void sessionShouldNotBeCreated() throws Exception {
mockMvc.perform(get("/actuator/health"))
.andExpect(status().isOk())
.andExpect(header().doesNotExist("Set-Cookie"));
}

@Test
void corsPreflightRequestShouldSucceed() throws Exception {
mockMvc.perform(options("/api/test")
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", "GET")
.header("Access-Control-Request-Headers", "Authorization"))
.andExpect(status().isOk())
.andExpect(header().exists("Access-Control-Allow-Origin"))
.andExpect(header().exists("Access-Control-Allow-Methods"));
}

@Test
void corsResponseShouldIncludeExpectedHeaders() throws Exception {
mockMvc.perform(options("/api/test")
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "Content-Type"))
.andExpect(status().isOk())
.andExpect(header().string("Access-Control-Allow-Origin", "http://localhost:3000"))
.andExpect(header().string("Access-Control-Allow-Credentials", "true"));
}

@Test
void otherActuatorEndpointsShouldRequireAuth() throws Exception {
mockMvc.perform(get("/actuator/env"))
.andExpect(status().isUnauthorized());
}

@Test
void prometheusEndpointShouldNotRequireAuth() throws Exception {
mockMvc.perform(get("/actuator/prometheus"))
.andExpect(status().isNotFound());
}
}
Loading
Loading