From baf55a0ffa2f8dd32ebd0728587ce568bed22f3c Mon Sep 17 00:00:00 2001 From: "aditya.chandel" Date: Sun, 8 Jun 2025 00:50:14 -0600 Subject: [PATCH] Make Swagger UI Access Configurable for Public or Restricted Use --- README.md | 1 + .../booklore/config/AppProperties.java | 8 ++- .../CustomOpdsUserDetailsService.java | 4 +- .../config/security/ImageCacheConfig.java | 17 +++++ .../config/security/OidcTokenValidator.java | 64 +++++++++++++++++ .../config/security/OpenApiConfig.java | 28 ++++++++ .../config/security/SecurityConfig.java | 71 ++++++++++++------- .../security/WebSocketAuthInterceptor.java | 62 +++------------- .../service/library/LibraryService.java | 6 +- .../src/main/resources/application.yaml | 7 +- 10 files changed, 183 insertions(+), 85 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/config/security/ImageCacheConfig.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/config/security/OidcTokenValidator.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/config/security/OpenApiConfig.java diff --git a/README.md b/README.md index da7692ff4..235fee823 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ services: - DATABASE_URL=jdbc:mariadb://mariadb:3306/booklore # Only modify this if you're familiar with JDBC and your database setup - DATABASE_USERNAME=booklore # Must match MYSQL_USER defined in the mariadb container - DATABASE_PASSWORD=your_secure_password # Use a strong password; must match MYSQL_PASSWORD defined in the mariadb container + - SWAGGER_ENABLED=false # Enable or disable Swagger UI (API docs). Set to 'true' to allow access; 'false' to block access (recommended for production). depends_on: mariadb: condition: service_healthy diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/AppProperties.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/AppProperties.java index f1a6674c1..0759c5639 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/AppProperties.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/AppProperties.java @@ -12,8 +12,8 @@ import org.springframework.stereotype.Component; public class AppProperties { private String pathConfig; private String version; - private RemoteAuth remoteAuth; + private Swagger swagger = new Swagger(); @Getter @Setter @@ -26,4 +26,10 @@ public class AppProperties { private String headerGroups; private String adminGroup; } + + @Getter + @Setter + public static class Swagger { + private boolean enabled = true; + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/CustomOpdsUserDetailsService.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/CustomOpdsUserDetailsService.java index 888979c6b..ea65efd64 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/CustomOpdsUserDetailsService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/CustomOpdsUserDetailsService.java @@ -18,9 +18,7 @@ public class CustomOpdsUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - OpdsUserEntity user = opdsUserRepository.findByUsername(username) - .orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(username)); - + OpdsUserEntity user = opdsUserRepository.findByUsername(username).orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(username)); return User.builder() .username(user.getUsername()) .password(user.getPassword()) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/ImageCacheConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/ImageCacheConfig.java new file mode 100644 index 000000000..58ca2031b --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/ImageCacheConfig.java @@ -0,0 +1,17 @@ +package com.adityachandel.booklore.config.security; + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ImageCacheConfig { + + @Bean + public FilterRegistrationBean imageCachingFilterRegistration() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new ImageCachingFilter()); + registrationBean.addUrlPatterns("/api/v1/books/*/cover"); + return registrationBean; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/OidcTokenValidator.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/OidcTokenValidator.java new file mode 100644 index 000000000..f5a754af3 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/OidcTokenValidator.java @@ -0,0 +1,64 @@ +package com.adityachandel.booklore.config.security; + +import com.adityachandel.booklore.model.dto.settings.OidcProviderDetails; +import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.RemoteJWKSet; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.util.DefaultResourceRetriever; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.URL; +import java.util.Collections; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OidcTokenValidator { + + private final AppSettingService appSettingService; + + public Authentication validate(String token) { + if (!appSettingService.getAppSettings().isOidcEnabled()) { + return null; + } + try { + OidcProviderDetails providerDetails = appSettingService.getAppSettings().getOidcProviderDetails(); + String jwksUrl = providerDetails.getJwksUrl(); + if (jwksUrl == null || jwksUrl.isEmpty()) { + log.error("JWKS URL is not configured"); + return null; + } + + URL jwksURL = new URI(jwksUrl).toURL(); + DefaultResourceRetriever resourceRetriever = new DefaultResourceRetriever(2000, 2000); + JWKSource jwkSource = new RemoteJWKSet<>(jwksURL, resourceRetriever); + ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); + JWSKeySelector keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource); + jwtProcessor.setJWSKeySelector(keySelector); + + JWTClaimsSet claimsSet = jwtProcessor.process(token, null); + Date expirationTime = claimsSet.getExpirationTime(); + if (expirationTime == null || expirationTime.before(new Date())) { + log.warn("OIDC token is expired or missing exp claim"); + return null; + } + return new UsernamePasswordAuthenticationToken("oidc-user", null, Collections.emptyList()); + } catch (Exception e) { + log.error("OIDC token validation failed", e); + return null; + } + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/OpenApiConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/OpenApiConfig.java new file mode 100644 index 000000000..da4e0e9aa --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/OpenApiConfig.java @@ -0,0 +1,28 @@ +package com.adityachandel.booklore.config.security; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI customOpenAPI() { + final String securitySchemeName = "bearerAuth"; + + return new OpenAPI() + .info(new Info().title("Booklore API").version("1.0")) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components().addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java index 279f918d7..f1a8a0963 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java @@ -1,9 +1,12 @@ package com.adityachandel.booklore.config.security; +import com.adityachandel.booklore.config.AppProperties; import lombok.AllArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; @@ -20,8 +23,12 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import jakarta.servlet.http.HttpServletResponse; + @AllArgsConstructor @EnableMethodSecurity @Configuration @@ -29,6 +36,24 @@ public class SecurityConfig { private final CustomOpdsUserDetailsService customOpdsUserDetailsService; private final DualJwtAuthenticationFilter dualJwtAuthenticationFilter; + private final AppProperties appProperties; + + private static final String[] SWAGGER_ENDPOINTS = { + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**" + }; + + private static final String[] COMMON_PUBLIC_ENDPOINTS = { + "/ws/**", + "/api/v1/auth/**", + "/api/v1/settings", + "/api/v1/setup/**", + "/api/v1/books/*/cover", + "/api/v1/opds/*/cover.jpg", + "/api/v1/cbx/*/pages/*", + "/api/v1/pdf/*/pages/*" + }; @Bean public PasswordEncoder passwordEncoder() { @@ -36,33 +61,36 @@ public class SecurityConfig { } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + @Order(1) + public SecurityFilterChain opdsBasicAuthSecurityChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/v1/opds/**") + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .httpBasic(basic -> basic.realmName("Booklore OPDS")) + .csrf(AbstractHttpConfigurer::disable); + return http.build(); + } + + @Bean + @Order(2) + public SecurityFilterChain jwtApiSecurityChain(HttpSecurity http) throws Exception { + List publicEndpoints = new ArrayList<>(Arrays.asList(COMMON_PUBLIC_ENDPOINTS)); + if (appProperties.getSwagger().isEnabled()) { + publicEndpoints.addAll(Arrays.asList(SWAGGER_ENDPOINTS)); + } http .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/api/v1/auth/**", - "/swagger-ui/**", - "/v3/api-docs/**", - "/ws/**", - "/api/v1/books/*/cover", - "/api/v1/settings", - "/api/v1/setup", - "/api/v1/setup/**", - "/api/v1/opds/*/cover.jpg", - "/api/v1/cbx/*/pages/*", - "/api/v1/pdf/*/pages/*" - ).permitAll() + .requestMatchers(publicEndpoints.toArray(new String[0])).permitAll() .anyRequest().authenticated() ) - .httpBasic(customizer -> customizer.realmName("Booklore OPDS")) .addFilterBefore(dualJwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - return http.build(); } + @Bean public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception { return http.getSharedObject(AuthenticationManagerBuilder.class) @@ -86,17 +114,10 @@ public class SecurityConfig { configuration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type")); configuration.setExposedHeaders(List.of("Content-Disposition")); configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); + return source; } - - @Bean - public FilterRegistrationBean loggingFilter() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new ImageCachingFilter()); - registrationBean.addUrlPatterns("/api/v1/books/*/cover"); - return registrationBean; - } - } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/WebSocketAuthInterceptor.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/WebSocketAuthInterceptor.java index bae347a54..12dd6cb16 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/WebSocketAuthInterceptor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/WebSocketAuthInterceptor.java @@ -1,17 +1,6 @@ package com.adityachandel.booklore.config.security; -import com.adityachandel.booklore.service.appsettings.AppSettingService; -import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.jwk.source.RemoteJWKSet; -import com.nimbusds.jose.proc.JWSKeySelector; -import com.nimbusds.jose.proc.JWSVerificationKeySelector; -import com.nimbusds.jose.proc.SecurityContext; -import com.nimbusds.jose.util.DefaultResourceRetriever; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; -import com.nimbusds.jwt.proc.DefaultJWTProcessor; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; @@ -26,20 +15,17 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Component; -import java.net.URI; -import java.net.URL; import java.util.Collections; -import java.util.Date; import java.util.List; @Slf4j @Component @Order(Ordered.HIGHEST_PRECEDENCE + 99) -@AllArgsConstructor +@RequiredArgsConstructor public class WebSocketAuthInterceptor implements ChannelInterceptor { private final JwtUtils jwtUtils; - private final AppSettingService appSettingService; + private final OidcTokenValidator oidcTokenValidator; @Override public Message preSend(Message message, MessageChannel channel) { @@ -53,7 +39,7 @@ public class WebSocketAuthInterceptor implements ChannelInterceptor { throw new IllegalArgumentException("Missing Authorization header"); } - String token = authHeaders.get(0).replace("Bearer ", ""); + String token = authHeaders.getFirst().replace("Bearer ", ""); Authentication auth = authenticateToken(token); if (auth == null) { @@ -71,41 +57,13 @@ public class WebSocketAuthInterceptor implements ChannelInterceptor { private Authentication authenticateToken(String token) { if (jwtUtils.validateToken(token)) { String username = jwtUtils.extractUsername(token); - List authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")); - return new UsernamePasswordAuthenticationToken(username, null, authorities); + return new UsernamePasswordAuthenticationToken( + username, + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); } - if (appSettingService.getAppSettings().isOidcEnabled()) { - try { - var providerDetails = appSettingService.getAppSettings().getOidcProviderDetails(); - String jwksUrl = providerDetails.getJwksUrl(); - if (jwksUrl == null || jwksUrl.isEmpty()) { - log.error("JWKS URL is not configured"); - return null; - } - - URL jwksURL = new URI(jwksUrl).toURL(); - DefaultResourceRetriever resourceRetriever = new DefaultResourceRetriever(2000, 2000); - JWKSource jwkSource = new RemoteJWKSet<>(jwksURL, resourceRetriever); - ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); - JWSKeySelector keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource); - jwtProcessor.setJWSKeySelector(keySelector); - - JWTClaimsSet claimsSet = jwtProcessor.process(token, null); - Date expirationTime = claimsSet.getExpirationTime(); - if (expirationTime == null || expirationTime.before(new Date())) { - log.warn("OIDC token is expired or missing exp claim"); - return null; - } - return new UsernamePasswordAuthenticationToken("oidc-user", null, Collections.emptyList()); - - } catch (Exception e) { - log.error("OIDC token validation failed", e); - return null; - } - } - - // If not OIDC-enabled, return null (or could throw an error depending on the requirement) - return null; + return oidcTokenValidator.validate(token); } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryService.java index 351b2ff29..0cb49ab1f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/LibraryService.java @@ -127,7 +127,7 @@ public class LibraryService { try { libraryProcessingService.processLibrary(libraryId); } catch (InvalidDataAccessApiUsageException e) { - log.warn("InvalidDataAccessApiUsageException - Library id: {}", libraryId); + log.debug("InvalidDataAccessApiUsageException - Library id: {}", libraryId); } catch (IOException e) { log.error("Error while parsing library books", e); } @@ -166,7 +166,7 @@ public class LibraryService { try { libraryProcessingService.processLibrary(libraryId); } catch (InvalidDataAccessApiUsageException e) { - log.warn("InvalidDataAccessApiUsageException - Library id: {}", libraryId); + log.debug("InvalidDataAccessApiUsageException - Library id: {}", libraryId); } catch (IOException e) { log.error("Error while parsing library books", e); } @@ -182,7 +182,7 @@ public class LibraryService { try { libraryProcessingService.rescanLibrary(libraryId); } catch (InvalidDataAccessApiUsageException e) { - log.warn("InvalidDataAccessApiUsageException - Library id: {}", libraryId); + log.debug("InvalidDataAccessApiUsageException - Library id: {}", libraryId); } catch (IOException e) { log.error("Error while parsing library books", e); } diff --git a/booklore-api/src/main/resources/application.yaml b/booklore-api/src/main/resources/application.yaml index 7bf058097..fde3cbb4f 100644 --- a/booklore-api/src/main/resources/application.yaml +++ b/booklore-api/src/main/resources/application.yaml @@ -1,7 +1,8 @@ app: path-config: '/app/data' version: 'v0.0.40' - + swagger: + enabled: ${SWAGGER_ENABLED:false} remote-auth: enabled: ${REMOTE_AUTH_ENABLED:false} create-new-users: ${REMOTE_AUTH_CREATE_NEW_USERS:true} @@ -45,6 +46,10 @@ spring: enabled: true locations: classpath:db/migration +springdoc: + swagger-ui: + persist-authorization: true + logging: level: root: ${ROOT_LOG_LEVEL:INFO}