mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
Make Swagger UI Access Configurable for Public or Restricted Use
This commit is contained in:
committed by
Aditya Chandel
parent
bbccedda52
commit
baf55a0ffa
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user