Make Swagger UI Access Configurable for Public or Restricted Use

This commit is contained in:
aditya.chandel
2025-06-08 00:50:14 -06:00
committed by Aditya Chandel
parent bbccedda52
commit baf55a0ffa
10 changed files with 183 additions and 85 deletions

View File

@@ -52,6 +52,7 @@ ### 2⃣ Create docker-compose.yml
- 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

View File

@@ -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;
}
}

View File

@@ -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())

View File

@@ -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<ImageCachingFilter> imageCachingFilterRegistration() {
FilterRegistrationBean<ImageCachingFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new ImageCachingFilter());
registrationBean.addUrlPatterns("/api/v1/books/*/cover");
return registrationBean;
}
}

View File

@@ -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<SecurityContext> jwkSource = new RemoteJWKSet<>(jwksURL, resourceRetriever);
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
JWSKeySelector<SecurityContext> 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;
}
}
}

View File

@@ -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")));
}
}

View File

@@ -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<String> 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<ImageCachingFilter> loggingFilter() {
FilterRegistrationBean<ImageCachingFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new ImageCachingFilter());
registrationBean.addUrlPatterns("/api/v1/books/*/cover");
return registrationBean;
}
}

View File

@@ -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<SimpleGrantedAuthority> 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<SecurityContext> jwkSource = new RemoteJWKSet<>(jwksURL, resourceRetriever);
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
JWSKeySelector<SecurityContext> 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);
}
}

View File

@@ -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);
}

View File

@@ -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}