Skip to content
Merged
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
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
Expand Down
100 changes: 75 additions & 25 deletions src/main/java/com/seth/routebook/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,35 @@
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource;

import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

/**
* Reads (GET) are public - anyone can browse the live demo without
* credentials. Writes (POST/PUT/DELETE) require HTTP Basic auth against
* a single admin account, provisioned in-memory from environment
* variables rather than a database-backed user table, since this app
* has exactly one operator, not a multi-user system.
* credentials. Writes (POST/PUT/DELETE) require a valid identity-service
* access token whose subject is on this app's admin allowlist.
*
* Authentication (who are you?) is delegated to identity-service: tokens
* are verified against its public JWKS. Authorization (what may you do
* here?) stays local to RouteBook.
*/
@Configuration
@EnableWebSecurity
Expand All @@ -36,28 +50,60 @@ public class SecurityConfig {
private final ApiAccessDeniedHandler accessDeniedHandler;
private final CorsConfigurationSource corsConfigurationSource;

@Value("${app.security.admin-username}")
private String adminUsername;
@Value("${app.security.jwk-set-uri}")
private String jwkSetUri;

@Value("${app.security.issuer}")
private String issuer;

@Value("${app.security.admin-password}")
private String adminPassword;
@Value("${app.security.admin-subjects:}")
private String adminSubjects;

/**
* Verifies RS256 signature (public key fetched lazily from the JWKS
* endpoint, so RouteBook still boots if identity-service is down),
* expiry, and issuer. Also rejects anything that isn't an access
* token: refresh tokens are signed with the same key, so signature
* validity alone would let a refresh token act as an access token.
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri)
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();

OAuth2TokenValidator<Jwt> accessTokenOnly = jwt ->
"access".equals(jwt.getClaimAsString("type"))
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "Not an access token", null));

decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer), accessTokenOnly));
return decoder;
}

@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
UserDetails admin = User.withUsername(adminUsername)
.password(passwordEncoder.encode(adminPassword))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(admin);
public JwtAuthenticationConverter jwtAuthenticationConverter() {
Set<String> admins = Arrays.stream(adminSubjects.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());

JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> authorities = admins.contains(jwt.getSubject())
? List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
: List.of();
return authorities;
});
return converter;
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
public SecurityFilterChain securityFilterChain(HttpSecurity http,
JwtAuthenticationConverter jwtAuthenticationConverter)
throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource))
Expand All @@ -66,9 +112,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/actuator/**", "/h2-console/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/**").permitAll()
.anyRequest().authenticated()
.anyRequest().hasRole("ADMIN")
)
.oauth2ResourceServer(oauth -> oauth
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter))
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
)
.httpBasic(basic -> basic.authenticationEntryPoint(authenticationEntryPoint))
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
Expand Down
13 changes: 8 additions & 5 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ spring.servlet.multipart.max-request-size=250MB
# CORS - dev frontend runs on Vite's default port
app.cors.allowed-origins=http://localhost:5173

# CORS - dev frontend runs on Vite's default port
app.cors.allowed-origins=http://localhost:5173

app.security.admin-username=${ADMIN_USERNAME:admin}
app.security.admin-password=${ADMIN_PASSWORD:admin}
# --- Identity service (shared SSO) ---
# Tokens are issued and signed by identity-service; RouteBook only verifies
# them against its public JWKS. No private key and no password hashes here.
app.security.jwk-set-uri=${IDENTITY_JWKS_URI:http://localhost:8081/.well-known/jwks.json}
app.security.issuer=${IDENTITY_ISSUER:identity-service}
# Comma-separated identity-service user IDs (the JWT "sub") allowed to write.
# Empty means nobody can write. RouteBook owns this authorization decision.
app.security.admin-subjects=${ADMIN_SUBJECTS:}
Loading
Loading